From 17f705859a25ff79ae19384f9304dd3090948468 Mon Sep 17 00:00:00 2001 From: krapes Date: Sun, 14 Jun 2020 13:32:08 -0400 Subject: [PATCH 01/43] Feature: moved locations and positions to start_apply --- easyapplybot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/easyapplybot.py b/easyapplybot.py index 2d48a5b..de54b97 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -23,7 +23,7 @@ class EasyApplyBot: - MAX_APPLICATIONS = 5 + MAX_APPLICATIONS = 50 def __init__(self,username,password, language, positions, locations, resumeloctn, appliedJobIDs=[], filename='output.csv'): From ce5e2cf6c255da534edd32f2e400fbfbeb5af94d Mon Sep 17 00:00:00 2001 From: krapes Date: Thu, 18 Jun 2020 22:11:04 -0400 Subject: [PATCH 02/43] Feature: Now running --- easyapplybot.py | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index de54b97..df70aee 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -16,6 +16,7 @@ import loginGUI from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager +import re driver = webdriver.Chrome(ChromeDriverManager().install()) @@ -23,17 +24,17 @@ class EasyApplyBot: - MAX_APPLICATIONS = 50 + MAX_APPLICATIONS = 10 - def __init__(self,username,password, language, positions, locations, resumeloctn, appliedJobIDs=[], filename='output.csv'): + def __init__(self,username,password, language, resumeloctn, appliedJobIDs=[], filename='output.csv'): print("\nWelcome to Easy Apply Bot\n") dirpath = os.getcwd() print("current directory is : " + dirpath) - self.positions = positions - self.locations = locations + #self.positions = positions + #self.locations = locations self.resumeloctn = resumeloctn self.language = language self.appliedJobIDs = appliedJobIDs @@ -97,11 +98,12 @@ def fill_data(self): print(self.resumeloctn) - def start_apply(self): + def start_apply(self, positions, locations): #self.wait_for_login() self.fill_data() - for position in self.positions: - for location in self.locations: + for position in positions: + while True: + location = locations[random.randint(0, len(locations) - 1)] print(f"Applying to {position}: {location}") location = "&location=" + location self.applications_loop(position, location) @@ -176,12 +178,7 @@ def applications_loop(self, position, location): position_number = str(count_job + jobs_per_page) print(f"\nPosition {position_number}:\n {self.browser.title} \n {string_easy} \n") - # append applied job ID to csv file - timestamp = datetime.datetime.now() - toWrite = [timestamp, jobID, str(self.browser.title).split(' | ')[0], str(self.browser.title).split(' | ')[1], button, result] - with open(self.filename,'a') as f: - writer = csv.writer(f) - writer.writerow(toWrite) + self.write_to_file(button, jobID, self.browser.title, result) # sleep every 20 applications if count_application != 0 and count_application % 20 == 0: @@ -203,7 +200,23 @@ def applications_loop(self, position, location): location, jobs_per_page) - + def write_to_file(self, button, jobID, browserTitle, result): + def re_extract(text, pattern): + target = re.search(pattern, text) + if target: + target = target.group(1) + return target + + timestamp = datetime.datetime.now() + attempted = False if button == False else True + #job = re.search(r"\(?\d?\)?\s?(\w.*)", browserTitle.split(' | ')[0]).group(1) + job = re_extract(browserTitle.split(' | ')[0], r"\(?\d?\)?\s?(\w.*)") + #company = re.search(r"(\w.*)", browserTitle.split(' | ')[1]).group(1) + company = re_extract(browserTitle.split(' | ')[1], r"(\w.*)" ) + toWrite = [timestamp, jobID, job, company, attempted, result] + with open(self.filename,'a') as f: + writer = csv.writer(f) + writer.writerow(toWrite) def get_job_links(self, page): links = [] @@ -279,6 +292,7 @@ def is_present(button_locator): submitted = False while True: button = None + #self.browser.find_element_by_xpath('//*[@id="file-browse-input"]').send_keys(self.resumeloctn) for i, button_locator in enumerate([next_locater, review_locater, submit_locater, submit_application_locator]): #print(i) if is_present(button_locator): From 1cbfa8e5172316d3af4cfa562810e0317658bd93 Mon Sep 17 00:00:00 2001 From: krapes Date: Sat, 20 Jun 2020 13:01:38 -0400 Subject: [PATCH 03/43] Feature: search timeout of 10 minutes --- easyapplybot.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 2d48a5b..99b932d 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -23,7 +23,7 @@ class EasyApplyBot: - MAX_APPLICATIONS = 5 + MAX_SEARCH_TIME = 10*60 def __init__(self,username,password, language, positions, locations, resumeloctn, appliedJobIDs=[], filename='output.csv'): @@ -112,6 +112,7 @@ def applications_loop(self, position, location): count_application = 0 count_job = 0 jobs_per_page = 0 + start_time = time.time() os.system("reset") @@ -126,7 +127,8 @@ def applications_loop(self, position, location): #self.browser.find_element_by_class_name("jobs-search-dropdown__option").click() #self.job_page = self.load_page(sleep=0.5) - while count_application < self.MAX_APPLICATIONS: + while start_time - time.time() < self.MAX_SEARCH_TIME: + print(f"{(start_time - time.time())/60} minutes left in this search") # sleep to make sure everything loads, add random to make us look human. time.sleep(random.uniform(3.5, 6.9)) From b0b4818a5d5cac4d720207317170640a17eb32c2 Mon Sep 17 00:00:00 2001 From: krapes Date: Sun, 21 Jun 2020 20:52:30 -0400 Subject: [PATCH 04/43] Feature: change search based on time --- easyapplybot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index fe2e83c..60735f1 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -131,8 +131,8 @@ def applications_loop(self, position, location): #self.browser.find_element_by_class_name("jobs-search-dropdown__option").click() #self.job_page = self.load_page(sleep=0.5) - while start_time - time.time() < self.MAX_SEARCH_TIME: - print(f"{(start_time - time.time())/60} minutes left in this search") + while time.time() - start_time < self.MAX_SEARCH_TIME: + print(f"{(time.time() - start_time)/60} minutes left in this search") # sleep to make sure everything loads, add random to make us look human. time.sleep(random.uniform(3.5, 6.9)) From 8f3d1ae522f05b1915d4536abc3a8271bda27435 Mon Sep 17 00:00:00 2001 From: krapes Date: Mon, 22 Jun 2020 09:43:54 -0400 Subject: [PATCH 05/43] Feature: Add state list --- states.json | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 states.json diff --git a/states.json b/states.json new file mode 100644 index 0000000..f2a89b5 --- /dev/null +++ b/states.json @@ -0,0 +1,61 @@ +{ + "AL": "Alabama", + "AK": "Alaska", + "AS": "American Samoa", + "AZ": "Arizona", + "AR": "Arkansas", + "CA": "California", + "CO": "Colorado", + "CT": "Connecticut", + "DE": "Delaware", + "DC": "District Of Columbia", + "FM": "Federated States Of Micronesia", + "FL": "Florida", + "GA": "Georgia", + "GU": "Guam", + "HI": "Hawaii", + "ID": "Idaho", + "IL": "Illinois", + "IN": "Indiana", + "IA": "Iowa", + "KS": "Kansas", + "KY": "Kentucky", + "LA": "Louisiana", + "ME": "Maine", + "MH": "Marshall Islands", + "MD": "Maryland", + "MA": "Massachusetts", + "MI": "Michigan", + "MN": "Minnesota", + "MS": "Mississippi", + "MO": "Missouri", + "MT": "Montana", + "NE": "Nebraska", + "NV": "Nevada", + "NH": "New Hampshire", + "NJ": "New Jersey", + "NM": "New Mexico", + "NY": "New York", + "NC": "North Carolina", + "ND": "North Dakota", + "MP": "Northern Mariana Islands", + "OH": "Ohio", + "OK": "Oklahoma", + "OR": "Oregon", + "PW": "Palau", + "PA": "Pennsylvania", + "PR": "Puerto Rico", + "RI": "Rhode Island", + "SC": "South Carolina", + "SD": "South Dakota", + "TN": "Tennessee", + "TX": "Texas", + "UT": "Utah", + "VT": "Vermont", + "VI": "Virgin Islands", + "VA": "Virginia", + "WA": "Washington", + "WV": "West Virginia", + "WI": "Wisconsin", + "WY": "Wyoming" +} \ No newline at end of file From 25a287ebffc17f891ad10dd09a2798d8aa00167f Mon Sep 17 00:00:00 2001 From: krapes Date: Wed, 24 Jun 2020 08:02:13 -0400 Subject: [PATCH 06/43] Feature: correctly print time remaining --- easyapplybot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/easyapplybot.py b/easyapplybot.py index 60735f1..249ec7d 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -132,7 +132,7 @@ def applications_loop(self, position, location): #self.job_page = self.load_page(sleep=0.5) while time.time() - start_time < self.MAX_SEARCH_TIME: - print(f"{(time.time() - start_time)/60} minutes left in this search") + print(f"{(self.MAX_SEARCH_TIME - (time.time() - start_time))//60} minutes left in this search") # sleep to make sure everything loads, add random to make us look human. time.sleep(random.uniform(3.5, 6.9)) From 0b8bac2b967b0aed70f279a5655bb7f343859ec5 Mon Sep 17 00:00:00 2001 From: krapes Date: Wed, 24 Jun 2020 08:32:44 -0400 Subject: [PATCH 07/43] Feature: create blacklist --- easyapplybot.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 249ec7d..d4a19ad 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -26,6 +26,7 @@ class EasyApplyBot: MAX_SEARCH_TIME = 10*60 + blacklist = ["Staffigo"] def __init__(self,username,password, language, resumeloctn, appliedJobIDs=[], filename='output.csv'): @@ -146,9 +147,14 @@ def applications_loop(self, position, location): # get job ID of each job link IDs = [] for link in links : - temp = link.get_attribute("data-job-id") - jobID = temp.split(":")[-1] - IDs.append(int(jobID)) + children = link.find_elements_by_xpath( + './/a[@data-control-name]' + ) + for child in children: + if child.text not in self.blacklist: + temp = link.get_attribute("data-job-id") + jobID = temp.split(":")[-1] + IDs.append(int(jobID)) IDs = set(IDs) # remove already applied jobs From 1277966c045719a44dc37495045133cde295eee1 Mon Sep 17 00:00:00 2001 From: krapes Date: Thu, 2 Jul 2020 08:14:56 -0400 Subject: [PATCH 08/43] Feature: Use jobIDs from output.csv --- easyapplybot.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index d4a19ad..84ca864 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -29,18 +29,15 @@ class EasyApplyBot: blacklist = ["Staffigo"] - def __init__(self,username,password, language, resumeloctn, appliedJobIDs=[], filename='output.csv'): + def __init__(self,username,password, language, resumeloctn, filename='output.csv'): print("\nWelcome to Easy Apply Bot\n") dirpath = os.getcwd() print("current directory is : " + dirpath) - - #self.positions = positions - #self.locations = locations self.resumeloctn = resumeloctn self.language = language - self.appliedJobIDs = appliedJobIDs + self.appliedJobIDs = self.get_appliedIDs(filename) self.filename = filename self.options = self.browser_options() self.browser = driver @@ -48,6 +45,13 @@ def __init__(self,username,password, language, resumeloctn, appliedJobIDs=[], fi self.start_linkedin(username,password) + def get_appliedIDs(self, filename): + + df = pd.read_csv(filename, + header=None, + names=['timestamp', 'jobID', 'job', 'company', 'attempted', 'result']) + return list(df.jobID) + def browser_options(self): options = Options() options.add_argument("--start-maximized") @@ -103,13 +107,14 @@ def fill_data(self): def start_apply(self, positions, locations): #self.wait_for_login() + start = time.time() self.fill_data() - for position in positions: - while True: - location = locations[random.randint(0, len(locations) - 1)] - print(f"Applying to {position}: {location}") - location = "&location=" + location - self.applications_loop(position, location) + while True: + position = positions[random.randint(0, len(positions) - 1)] + location = locations[random.randint(0, len(locations) - 1)] + print(f"Applying to {position}: {location}") + location = "&location=" + location + self.applications_loop(position, location) self.finish_apply() def applications_loop(self, position, location): @@ -144,6 +149,9 @@ def applications_loop(self, position, location): '//div[@data-job-id]' ) + if len(links) == 0: + break + # get job ID of each job link IDs = [] for link in links : @@ -169,7 +177,7 @@ def applications_loop(self, position, location): jobs_per_page) # loop over IDs to apply - for jobID in jobIDs: + for i, jobID in enumerate(jobIDs): count_job += 1 self.get_job_page(jobID) @@ -209,6 +217,8 @@ def applications_loop(self, position, location): self.browser, jobs_per_page = self.next_jobs_page(position, location, jobs_per_page) + if len(jobIDs) == 0 or i == (len(jobIDs) - 1): + break def write_to_file(self, button, jobID, browserTitle, result): def re_extract(text, pattern): From bbda612a914c835668f794cfb0555d1dac70e532 Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 10 Jul 2020 15:54:31 -0400 Subject: [PATCH 09/43] Feature: Support for bad filename --- easyapplybot.py | 84 ++++++------------------------------------------- 1 file changed, 9 insertions(+), 75 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 84ca864..0f41d5d 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -29,7 +29,7 @@ class EasyApplyBot: blacklist = ["Staffigo"] - def __init__(self,username,password, language, resumeloctn, filename='output.csv'): + def __init__(self,username,password, language, resumeloctn, filename='error.csv'): print("\nWelcome to Easy Apply Bot\n") dirpath = os.getcwd() @@ -46,21 +46,21 @@ def __init__(self,username,password, language, resumeloctn, filename='output.cs def get_appliedIDs(self, filename): + try: + df = pd.read_csv(filename, + header=None, + names=['timestamp', 'jobID', 'job', 'company', 'attempted', 'result']) + return list(df.jobID) + except Exception as e: + print(str(e) + " jobIDs could not be loaded from CSV {}".format(filename)) + return None - df = pd.read_csv(filename, - header=None, - names=['timestamp', 'jobID', 'job', 'company', 'attempted', 'result']) - return list(df.jobID) def browser_options(self): options = Options() options.add_argument("--start-maximized") options.add_argument("--ignore-certificate-errors") - #options.add_argument("user-agent=Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393") - #options.add_argument('--headless') options.add_argument('--no-sandbox') - #options.add_argument('--disable-gpu') - #options.add_argument('disable-infobars') options.add_argument("--disable-extensions") return options @@ -101,7 +101,6 @@ def wait_for_login(self): def fill_data(self): self.browser.set_window_size(0, 0) self.browser.set_window_position(2000, 2000) - os.system("reset") print(self.resumeloctn) @@ -124,7 +123,6 @@ def applications_loop(self, position, location): jobs_per_page = 0 start_time = time.time() - os.system("reset") print("\nLooking for jobs.. Please wait..\n") @@ -132,10 +130,6 @@ def applications_loop(self, position, location): self.browser.maximize_window() self.browser, _ = self.next_jobs_page(position, location, jobs_per_page) print("\nLooking for jobs.. Please wait..\n") - #below was causing issues, and not sure what they are for. - #self.browser.find_element_by_class_name("jobs-search-dropdown__trigger-icon").click() - #self.browser.find_element_by_class_name("jobs-search-dropdown__option").click() - #self.job_page = self.load_page(sleep=0.5) while time.time() - start_time < self.MAX_SEARCH_TIME: print(f"{(self.MAX_SEARCH_TIME - (time.time() - start_time))//60} minutes left in this search") @@ -390,63 +384,3 @@ def next_jobs_page(self, position, location, jobs_per_page): def finish_apply(self): self.browser.close() - -if __name__ == '__main__': - - # set use of gui (T/F) - - useGUI = True - #useGUI = False - - # use gui - if useGUI == True: - - app = loginGUI.LoginGUI() - app.mainloop() - - #get user info info - username=app.frames["StartPage"].username - password=app.frames["StartPage"].password - language=app.frames["PageOne"].language - position=app.frames["PageTwo"].position - location_code=app.frames["PageThree"].location_code - if location_code == 1: - location=app.frames["PageThree"].location - else: - location = app.frames["PageFour"].location - resumeloctn=app.frames["PageFive"].resumeloctn - - # no gui - if useGUI == False: - - username = '' - password = '' - language = 'en' - position = 'marketing' - location = '' - resumeloctn = '' - - # print input - print("\nThese is your input:") - - print( - "\nUsername: "+ username, - "\nPassword: "+ password, - "\nLanguage: "+ language, - "\nPosition: "+ position, - "\nLocation: "+ location - ) - - print("\nLet's scrape some jobs!\n") - - # get list of already applied jobs - filename = 'joblist.csv' - try: - df = pd.read_csv(filename, header=None) - appliedJobIDs = list (df.iloc[:,1]) - except: - appliedJobIDs = [] - - # start bot - bot = EasyApplyBot(username, password, language, position, location, resumeloctn, appliedJobIDs, filename) - bot.start_apply() From 78f4d8096df4f10e01a1a196f0c6181fa7c8cfcd Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 10 Jul 2020 16:03:34 -0400 Subject: [PATCH 10/43] Refactor: Remove pieces of language options that were not working --- easyapplybot.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 0f41d5d..ce13755 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -29,7 +29,7 @@ class EasyApplyBot: blacklist = ["Staffigo"] - def __init__(self,username,password, language, resumeloctn, filename='error.csv'): + def __init__(self, username, password, language, resumeloctn, filename='output.csv'): print("\nWelcome to Easy Apply Bot\n") dirpath = os.getcwd() @@ -81,12 +81,7 @@ def start_linkedin(self,username,password): print("TimeoutException! Username/password field or login button not found") def wait_for_login(self): - if language == "en": - title = "Sign In to LinkedIn" - elif language == "es": - title = "Inicia sesiĆ³n" - elif language == "pt": - title = "Entrar no LinkedIn" + time.sleep(1) @@ -260,14 +255,14 @@ def got_easy_apply(self, page): return EasyApplyButton else : return False - #return len(str(button)) > 4 + def get_easy_apply_button(self): try : button = self.browser.find_elements_by_xpath( '//button[contains(@class, "jobs-apply")]/span[1]' ) - #if button[0].text in "Easy Apply" : + EasyApplyButton = button [0] except : EasyApplyButton = False @@ -346,7 +341,7 @@ def is_present(button_locator): except Exception as e: print(e) print("cannot apply to this job") - raise(e) + #raise(e) return submitted @@ -382,5 +377,6 @@ def next_jobs_page(self, position, location, jobs_per_page): self.load_page() return (self.browser, jobs_per_page) + def finish_apply(self): self.browser.close() From eeee227cfc8660d0d4760240266dcce8fd75ebb6 Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 10 Jul 2020 16:12:47 -0400 Subject: [PATCH 11/43] Refactor --- easyapplybot.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index ce13755..379d493 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -100,15 +100,19 @@ def fill_data(self): print(self.resumeloctn) def start_apply(self, positions, locations): - #self.wait_for_login() start = time.time() self.fill_data() - while True: + + combos = [] + while len(combos) < len(positions) * len(locations): position = positions[random.randint(0, len(positions) - 1)] location = locations[random.randint(0, len(locations) - 1)] - print(f"Applying to {position}: {location}") - location = "&location=" + location - self.applications_loop(position, location) + combo = (position, location) + if combo not in combos: + combos.append(combo) + print(f"Applying to {position}: {location}") + location = "&location=" + location + self.applications_loop(position, location) self.finish_apply() def applications_loop(self, position, location): @@ -218,10 +222,9 @@ def re_extract(text, pattern): timestamp = datetime.datetime.now() attempted = False if button == False else True - #job = re.search(r"\(?\d?\)?\s?(\w.*)", browserTitle.split(' | ')[0]).group(1) job = re_extract(browserTitle.split(' | ')[0], r"\(?\d?\)?\s?(\w.*)") - #company = re.search(r"(\w.*)", browserTitle.split(' | ')[1]).group(1) company = re_extract(browserTitle.split(' | ')[1], r"(\w.*)" ) + toWrite = [timestamp, jobID, job, company, attempted, result] with open(self.filename,'a') as f: writer = csv.writer(f) From 977795c0ef0b80c79cfe24c85fdb4139f2fdb85b Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 10 Jul 2020 16:51:02 -0400 Subject: [PATCH 12/43] Feature: yaml configuration --- config.yaml | 11 + easyapplybot.py | 741 +++++++++++++++++++++++++----------------------- 2 files changed, 392 insertions(+), 360 deletions(-) create mode 100644 config.yaml diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..c549cd7 --- /dev/null +++ b/config.yaml @@ -0,0 +1,11 @@ +username: # Insert your username here +password: # Insert your password here + +positions: +- # Position you want to search for +- # Another position you want to search for +- # A third position you want to search for + +locations: +- # Location you want to search in +- # A second location you want to search in \ No newline at end of file diff --git a/easyapplybot.py b/easyapplybot.py index 379d493..e3fea75 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -10,13 +10,14 @@ from bs4 import BeautifulSoup import pandas as pd import pyautogui -from tkinter import filedialog, Tk -import tkinter.messagebox as tm +#from tkinter import filedialog, Tk +#import tkinter.messagebox as tm from urllib.request import urlopen import loginGUI from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager import re +import yaml driver = webdriver.Chrome(ChromeDriverManager().install()) @@ -25,361 +26,381 @@ class EasyApplyBot: - MAX_SEARCH_TIME = 10*60 - blacklist = ["Staffigo"] - - - def __init__(self, username, password, language, resumeloctn, filename='output.csv'): - - print("\nWelcome to Easy Apply Bot\n") - dirpath = os.getcwd() - print("current directory is : " + dirpath) - - self.resumeloctn = resumeloctn - self.language = language - self.appliedJobIDs = self.get_appliedIDs(filename) - self.filename = filename - self.options = self.browser_options() - self.browser = driver - self.wait = WebDriverWait(self.browser, 30) - self.start_linkedin(username,password) - - - def get_appliedIDs(self, filename): - try: - df = pd.read_csv(filename, - header=None, - names=['timestamp', 'jobID', 'job', 'company', 'attempted', 'result']) - return list(df.jobID) - except Exception as e: - print(str(e) + " jobIDs could not be loaded from CSV {}".format(filename)) - return None - - - def browser_options(self): - options = Options() - options.add_argument("--start-maximized") - options.add_argument("--ignore-certificate-errors") - options.add_argument('--no-sandbox') - options.add_argument("--disable-extensions") - return options - - def start_linkedin(self,username,password): - print("\nLogging in.....\n \nPlease wait :) \n ") - self.browser.get("https://www.linkedin.com/login?trk=guest_homepage-basic_nav-header-signin") - try: - user_field = self.browser.find_element_by_id("username") - pw_field = self.browser.find_element_by_id("password") - login_button = self.browser.find_element_by_css_selector(".btn__primary--large") - user_field.send_keys(username) - user_field.send_keys(Keys.TAB) - time.sleep(1) - pw_field.send_keys(password) - time.sleep(1) - login_button.click() - except TimeoutException: - print("TimeoutException! Username/password field or login button not found") - - def wait_for_login(self): - - - time.sleep(1) - - while True: - if self.browser.title != title: - print("\nStarting LinkedIn bot\n") - break - else: - time.sleep(1) - print("\nPlease Login to your LinkedIn account\n") - - def fill_data(self): - self.browser.set_window_size(0, 0) - self.browser.set_window_position(2000, 2000) - - print(self.resumeloctn) - - def start_apply(self, positions, locations): - start = time.time() - self.fill_data() - - combos = [] - while len(combos) < len(positions) * len(locations): - position = positions[random.randint(0, len(positions) - 1)] - location = locations[random.randint(0, len(locations) - 1)] - combo = (position, location) - if combo not in combos: - combos.append(combo) - print(f"Applying to {position}: {location}") - location = "&location=" + location - self.applications_loop(position, location) - self.finish_apply() - - def applications_loop(self, position, location): - - count_application = 0 - count_job = 0 - jobs_per_page = 0 - start_time = time.time() - - - print("\nLooking for jobs.. Please wait..\n") - - self.browser.set_window_position(0, 0) - self.browser.maximize_window() - self.browser, _ = self.next_jobs_page(position, location, jobs_per_page) - print("\nLooking for jobs.. Please wait..\n") - - while time.time() - start_time < self.MAX_SEARCH_TIME: - print(f"{(self.MAX_SEARCH_TIME - (time.time() - start_time))//60} minutes left in this search") - - # sleep to make sure everything loads, add random to make us look human. - time.sleep(random.uniform(3.5, 6.9)) - self.load_page(sleep=1) - - # get job links - links = self.browser.find_elements_by_xpath( - '//div[@data-job-id]' - ) - - if len(links) == 0: - break - - # get job ID of each job link - IDs = [] - for link in links : - children = link.find_elements_by_xpath( - './/a[@data-control-name]' - ) - for child in children: - if child.text not in self.blacklist: - temp = link.get_attribute("data-job-id") - jobID = temp.split(":")[-1] - IDs.append(int(jobID)) - IDs = set(IDs) - - # remove already applied jobs - jobIDs = [x for x in IDs if x not in self.appliedJobIDs] - - if len(jobIDs) == 0: - jobs_per_page = jobs_per_page + 25 - count_job = 0 - self.avoid_lock() - self.browser, jobs_per_page = self.next_jobs_page(position, - location, - jobs_per_page) - - # loop over IDs to apply - for i, jobID in enumerate(jobIDs): - count_job += 1 - self.get_job_page(jobID) - - # get easy apply button - button = self.get_easy_apply_button () - if button is not False: - string_easy = "* has Easy Apply Button" - button.click() - time.sleep (3) - result = self.send_resume() - count_application += 1 - else: - string_easy = "* Doesn't have Easy Apply Button" - result = False - - position_number = str(count_job + jobs_per_page) - print(f"\nPosition {position_number}:\n {self.browser.title} \n {string_easy} \n") - - self.write_to_file(button, jobID, self.browser.title, result) - - # sleep every 20 applications - if count_application != 0 and count_application % 20 == 0: - sleepTime = random.randint(500, 900) - print(f'\n\n********count_application: {count_application}************\n\n') - print(f"Time for a nap - see you in:{int(sleepTime/60)} min") - print('\n\n****************************************\n\n') - time.sleep (sleepTime) - - # go to new page if all jobs are done - if count_job == len(jobIDs): - jobs_per_page = jobs_per_page + 25 - count_job = 0 - print('\n\n****************************************\n\n') - print('Going to next jobs page, YEAAAHHH!!') - print('\n\n****************************************\n\n') - self.avoid_lock() - self.browser, jobs_per_page = self.next_jobs_page(position, - location, - jobs_per_page) - if len(jobIDs) == 0 or i == (len(jobIDs) - 1): - break - - def write_to_file(self, button, jobID, browserTitle, result): - def re_extract(text, pattern): - target = re.search(pattern, text) - if target: - target = target.group(1) - return target - - timestamp = datetime.datetime.now() - attempted = False if button == False else True - job = re_extract(browserTitle.split(' | ')[0], r"\(?\d?\)?\s?(\w.*)") - company = re_extract(browserTitle.split(' | ')[1], r"(\w.*)" ) - - toWrite = [timestamp, jobID, job, company, attempted, result] - with open(self.filename,'a') as f: - writer = csv.writer(f) - writer.writerow(toWrite) - - def get_job_links(self, page): - links = [] - for link in page.find_all('a'): - url = link.get('href') - if url: - if '/jobs/view' in url: - links.append(url) - return set(links) - - def get_job_page(self, jobID): - #root = 'www.linkedin.com' - #if root not in job: - job = 'https://www.linkedin.com/jobs/view/'+ str(jobID) - self.browser.get(job) - self.job_page = self.load_page(sleep=0.5) - return self.job_page - - def got_easy_apply(self, page): - #button = page.find("button", class_="jobs-apply-button artdeco-button jobs-apply-button--top-card artdeco-button--3 ember-view") - - button = self.browser.find_elements_by_xpath( - '//button[contains(@class, "jobs-apply")]/span[1]' - ) - EasyApplyButton = button [0] - if EasyApplyButton.text in "Easy Apply" : - return EasyApplyButton - else : - return False - - - def get_easy_apply_button(self): - try : - button = self.browser.find_elements_by_xpath( - '//button[contains(@class, "jobs-apply")]/span[1]' - ) - - EasyApplyButton = button [0] - except : - EasyApplyButton = False - - return EasyApplyButton - - def easy_apply_xpath(self): - button = self.get_easy_apply_button() - button_inner_html = str(button) - list_of_words = button_inner_html.split() - next_word = [word for word in list_of_words if "ember" in word and "id" in word] - ember = next_word[0][:-1] - xpath = '//*[@'+ember+']/button' - return xpath - - def click_button(self, xpath): - triggerDropDown = self.browser.find_element_by_xpath(xpath) - time.sleep(0.5) - triggerDropDown.click() - time.sleep(1) - - def send_resume(self): - def is_present(button_locator): - return len(self.browser.find_elements(button_locator[0], - button_locator[1])) > 0 - - try: - time.sleep(3) - #print(f"Navigating... ") - next_locater = (By.CSS_SELECTOR, "button[aria-label='Continue to next step']") - review_locater = (By.CSS_SELECTOR, "button[aria-label='Review your application']") - submit_locater = (By.CSS_SELECTOR, "button[aria-label='Submit application']") - submit_application_locator = (By.CSS_SELECTOR, "button[aria-label='Submit application']") - error_locator = (By.CSS_SELECTOR, "p[data-test-form-element-error-message='true']") - - submitted = False - while True: - button = None - #self.browser.find_element_by_xpath('//*[@id="file-browse-input"]').send_keys(self.resumeloctn) - for i, button_locator in enumerate([next_locater, review_locater, submit_locater, submit_application_locator]): - #print(i) - if is_present(button_locator): - #print("button found") - button = self.wait.until(EC.element_to_be_clickable(button_locator)) - - if is_present(error_locator): - for element in self.browser.find_elements(error_locator[0], - error_locator[1]): - text = element.text - if "Please enter a valid answer" in text: - #print("Error Found") - #print(element.get_attribute('class')) - button = None - break - if button: - button.click() - time.sleep(random.uniform(1.5, 2.5)) - if i in (2, 3): - submitted = True - break - if button == None: - print("Could not complete submission") - break - elif submitted: - print("Application Submitted") - break - - time.sleep(random.uniform(1.5, 2.5)) - - #After submiting the application, a dialog shows up, we need to close this dialog - close_button_locator = (By.CSS_SELECTOR, "button[aria-label='Dismiss']") - if is_present(close_button_locator): - close_button = self.wait.until(EC.element_to_be_clickable(close_button_locator)) - close_button.click() - - except Exception as e: - print(e) - print("cannot apply to this job") - #raise(e) - - return submitted - - def load_page(self, sleep=1): - scroll_page = 0 - while scroll_page < 4000: - self.browser.execute_script("window.scrollTo(0,"+str(scroll_page)+" );") - scroll_page += 200 - time.sleep(sleep) - - if sleep != 1: - self.browser.execute_script("window.scrollTo(0,0);") - time.sleep(sleep * 3) - - page = BeautifulSoup(self.browser.page_source, "lxml") - return page - - def avoid_lock(self): - x, _ = pyautogui.position() - pyautogui.moveTo(x + 200, pyautogui.position().y, duration=1.0) - pyautogui.moveTo(x, pyautogui.position().y, duration=0.5) - pyautogui.keyDown('ctrl') - pyautogui.press('esc') - pyautogui.keyUp('ctrl') - time.sleep(0.5) - pyautogui.press('esc') - - def next_jobs_page(self, position, location, jobs_per_page): - self.browser.get( - "https://www.linkedin.com/jobs/search/?f_LF=f_AL&keywords=" + - position + location + "&start="+str(jobs_per_page)) - self.avoid_lock() - self.load_page() - return (self.browser, jobs_per_page) - - - def finish_apply(self): - self.browser.close() + MAX_SEARCH_TIME = 10*60 + blacklist = ["Staffigo"] + + + def __init__(self, username, password, language, resumeloctn=None, filename='output.csv'): + + print("\nWelcome to Easy Apply Bot\n") + dirpath = os.getcwd() + print("current directory is : " + dirpath) + + self.resumeloctn = resumeloctn + self.language = language + self.appliedJobIDs = self.get_appliedIDs(filename) + self.filename = filename + self.options = self.browser_options() + self.browser = driver + self.wait = WebDriverWait(self.browser, 30) + self.start_linkedin(username,password) + + + def get_appliedIDs(self, filename): + try: + df = pd.read_csv(filename, + header=None, + names=['timestamp', 'jobID', 'job', 'company', 'attempted', 'result']) + return list(df.jobID) + except Exception as e: + print(str(e) + " jobIDs could not be loaded from CSV {}".format(filename)) + return None + + + def browser_options(self): + options = Options() + options.add_argument("--start-maximized") + options.add_argument("--ignore-certificate-errors") + options.add_argument('--no-sandbox') + options.add_argument("--disable-extensions") + return options + + def start_linkedin(self,username,password): + print("\nLogging in.....\n \nPlease wait :) \n ") + self.browser.get("https://www.linkedin.com/login?trk=guest_homepage-basic_nav-header-signin") + try: + user_field = self.browser.find_element_by_id("username") + pw_field = self.browser.find_element_by_id("password") + login_button = self.browser.find_element_by_css_selector(".btn__primary--large") + user_field.send_keys(username) + user_field.send_keys(Keys.TAB) + time.sleep(1) + pw_field.send_keys(password) + time.sleep(1) + login_button.click() + except TimeoutException: + print("TimeoutException! Username/password field or login button not found") + + def wait_for_login(self): + + + time.sleep(1) + + while True: + if self.browser.title != title: + print("\nStarting LinkedIn bot\n") + break + else: + time.sleep(1) + print("\nPlease Login to your LinkedIn account\n") + + def fill_data(self): + self.browser.set_window_size(0, 0) + self.browser.set_window_position(2000, 2000) + + print(self.resumeloctn) + + def start_apply(self, positions, locations): + start = time.time() + self.fill_data() + + combos = [] + while len(combos) < len(positions) * len(locations): + position = positions[random.randint(0, len(positions) - 1)] + location = locations[random.randint(0, len(locations) - 1)] + combo = (position, location) + if combo not in combos: + combos.append(combo) + print(f"Applying to {position}: {location}") + location = "&location=" + location + self.applications_loop(position, location) + self.finish_apply() + + def applications_loop(self, position, location): + + count_application = 0 + count_job = 0 + jobs_per_page = 0 + start_time = time.time() + + + print("\nLooking for jobs.. Please wait..\n") + + self.browser.set_window_position(0, 0) + self.browser.maximize_window() + self.browser, _ = self.next_jobs_page(position, location, jobs_per_page) + print("\nLooking for jobs.. Please wait..\n") + + while time.time() - start_time < self.MAX_SEARCH_TIME: + print(f"{(self.MAX_SEARCH_TIME - (time.time() - start_time))//60} minutes left in this search") + + # sleep to make sure everything loads, add random to make us look human. + time.sleep(random.uniform(3.5, 6.9)) + self.load_page(sleep=1) + + # get job links + links = self.browser.find_elements_by_xpath( + '//div[@data-job-id]' + ) + + if len(links) == 0: + break + + # get job ID of each job link + IDs = [] + for link in links : + children = link.find_elements_by_xpath( + './/a[@data-control-name]' + ) + for child in children: + if child.text not in self.blacklist: + temp = link.get_attribute("data-job-id") + jobID = temp.split(":")[-1] + IDs.append(int(jobID)) + IDs = set(IDs) + + # remove already applied jobs + jobIDs = [x for x in IDs if x not in self.appliedJobIDs] + + if len(jobIDs) == 0: + jobs_per_page = jobs_per_page + 25 + count_job = 0 + self.avoid_lock() + self.browser, jobs_per_page = self.next_jobs_page(position, + location, + jobs_per_page) + + # loop over IDs to apply + for i, jobID in enumerate(jobIDs): + count_job += 1 + self.get_job_page(jobID) + + # get easy apply button + button = self.get_easy_apply_button () + if button is not False: + string_easy = "* has Easy Apply Button" + button.click() + time.sleep (3) + result = self.send_resume() + count_application += 1 + else: + string_easy = "* Doesn't have Easy Apply Button" + result = False + + position_number = str(count_job + jobs_per_page) + print(f"\nPosition {position_number}:\n {self.browser.title} \n {string_easy} \n") + + self.write_to_file(button, jobID, self.browser.title, result) + + # sleep every 20 applications + if count_application != 0 and count_application % 20 == 0: + sleepTime = random.randint(500, 900) + print(f'\n\n********count_application: {count_application}************\n\n') + print(f"Time for a nap - see you in:{int(sleepTime/60)} min") + print('\n\n****************************************\n\n') + time.sleep (sleepTime) + + # go to new page if all jobs are done + if count_job == len(jobIDs): + jobs_per_page = jobs_per_page + 25 + count_job = 0 + print('\n\n****************************************\n\n') + print('Going to next jobs page, YEAAAHHH!!') + print('\n\n****************************************\n\n') + self.avoid_lock() + self.browser, jobs_per_page = self.next_jobs_page(position, + location, + jobs_per_page) + if len(jobIDs) == 0 or i == (len(jobIDs) - 1): + break + + def write_to_file(self, button, jobID, browserTitle, result): + def re_extract(text, pattern): + target = re.search(pattern, text) + if target: + target = target.group(1) + return target + + timestamp = datetime.datetime.now() + attempted = False if button == False else True + job = re_extract(browserTitle.split(' | ')[0], r"\(?\d?\)?\s?(\w.*)") + company = re_extract(browserTitle.split(' | ')[1], r"(\w.*)" ) + + toWrite = [timestamp, jobID, job, company, attempted, result] + with open(self.filename,'a') as f: + writer = csv.writer(f) + writer.writerow(toWrite) + + def get_job_links(self, page): + links = [] + for link in page.find_all('a'): + url = link.get('href') + if url: + if '/jobs/view' in url: + links.append(url) + return set(links) + + def get_job_page(self, jobID): + #root = 'www.linkedin.com' + #if root not in job: + job = 'https://www.linkedin.com/jobs/view/'+ str(jobID) + self.browser.get(job) + self.job_page = self.load_page(sleep=0.5) + return self.job_page + + def got_easy_apply(self, page): + #button = page.find("button", class_="jobs-apply-button artdeco-button jobs-apply-button--top-card artdeco-button--3 ember-view") + + button = self.browser.find_elements_by_xpath( + '//button[contains(@class, "jobs-apply")]/span[1]' + ) + EasyApplyButton = button [0] + if EasyApplyButton.text in "Easy Apply" : + return EasyApplyButton + else : + return False + + + def get_easy_apply_button(self): + try : + button = self.browser.find_elements_by_xpath( + '//button[contains(@class, "jobs-apply")]/span[1]' + ) + + EasyApplyButton = button [0] + except : + EasyApplyButton = False + + return EasyApplyButton + + def easy_apply_xpath(self): + button = self.get_easy_apply_button() + button_inner_html = str(button) + list_of_words = button_inner_html.split() + next_word = [word for word in list_of_words if "ember" in word and "id" in word] + ember = next_word[0][:-1] + xpath = '//*[@'+ember+']/button' + return xpath + + def click_button(self, xpath): + triggerDropDown = self.browser.find_element_by_xpath(xpath) + time.sleep(0.5) + triggerDropDown.click() + time.sleep(1) + + def send_resume(self): + def is_present(button_locator): + return len(self.browser.find_elements(button_locator[0], + button_locator[1])) > 0 + + try: + time.sleep(3) + #print(f"Navigating... ") + next_locater = (By.CSS_SELECTOR, "button[aria-label='Continue to next step']") + review_locater = (By.CSS_SELECTOR, "button[aria-label='Review your application']") + submit_locater = (By.CSS_SELECTOR, "button[aria-label='Submit application']") + submit_application_locator = (By.CSS_SELECTOR, "button[aria-label='Submit application']") + error_locator = (By.CSS_SELECTOR, "p[data-test-form-element-error-message='true']") + + submitted = False + while True: + button = None + #self.browser.find_element_by_xpath('//*[@id="file-browse-input"]').send_keys(self.resumeloctn) + for i, button_locator in enumerate([next_locater, review_locater, submit_locater, submit_application_locator]): + #print(i) + if is_present(button_locator): + #print("button found") + button = self.wait.until(EC.element_to_be_clickable(button_locator)) + + if is_present(error_locator): + for element in self.browser.find_elements(error_locator[0], + error_locator[1]): + text = element.text + if "Please enter a valid answer" in text: + #print("Error Found") + #print(element.get_attribute('class')) + button = None + break + if button: + button.click() + time.sleep(random.uniform(1.5, 2.5)) + if i in (2, 3): + submitted = True + break + if button == None: + print("Could not complete submission") + break + elif submitted: + print("Application Submitted") + break + + time.sleep(random.uniform(1.5, 2.5)) + + #After submiting the application, a dialog shows up, we need to close this dialog + close_button_locator = (By.CSS_SELECTOR, "button[aria-label='Dismiss']") + if is_present(close_button_locator): + close_button = self.wait.until(EC.element_to_be_clickable(close_button_locator)) + close_button.click() + + except Exception as e: + print(e) + print("cannot apply to this job") + #raise(e) + + return submitted + + def load_page(self, sleep=1): + scroll_page = 0 + while scroll_page < 4000: + self.browser.execute_script("window.scrollTo(0,"+str(scroll_page)+" );") + scroll_page += 200 + time.sleep(sleep) + + if sleep != 1: + self.browser.execute_script("window.scrollTo(0,0);") + time.sleep(sleep * 3) + + page = BeautifulSoup(self.browser.page_source, "lxml") + return page + + def avoid_lock(self): + x, _ = pyautogui.position() + pyautogui.moveTo(x + 200, pyautogui.position().y, duration=1.0) + pyautogui.moveTo(x, pyautogui.position().y, duration=0.5) + pyautogui.keyDown('ctrl') + pyautogui.press('esc') + pyautogui.keyUp('ctrl') + time.sleep(0.5) + pyautogui.press('esc') + + def next_jobs_page(self, position, location, jobs_per_page): + self.browser.get( + "https://www.linkedin.com/jobs/search/?f_LF=f_AL&keywords=" + + position + location + "&start="+str(jobs_per_page)) + self.avoid_lock() + self.load_page() + return (self.browser, jobs_per_page) + + + def finish_apply(self): + self.browser.close() + +if __name__ == '__main__': + + with open("config.yaml", 'r') as stream: + try: + parameters = yaml.safe_load(stream) + except yaml.YAMLError as exc: + raise exc + + assert len(parameters['positions']) > 0 + assert len(parameters['locations']) > 0 + assert parameters['username'] is not None + assert parameters['password'] is not None + + bot = EasyApplyBot(parameters['username'], + parameters['password'], + parameters['locations'] + ) + + bot.start_apply(parameters['positions'], parameters['locations']) \ No newline at end of file From 8e7d143534bdb6fa0a8e3addb5a6a2aaa6547cd3 Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 10 Jul 2020 16:52:16 -0400 Subject: [PATCH 13/43] Refactoring: remove unnecessary imports --- easyapplybot.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index e3fea75..d85e135 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -10,18 +10,14 @@ from bs4 import BeautifulSoup import pandas as pd import pyautogui -#from tkinter import filedialog, Tk -#import tkinter.messagebox as tm + from urllib.request import urlopen -import loginGUI -from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager import re import yaml driver = webdriver.Chrome(ChromeDriverManager().install()) -# pyinstaller --onefile --windowed --icon=app.ico easyapplybot.py class EasyApplyBot: From 39ae932da9a5680210920486430e7e1755f38e14 Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 10 Jul 2020 17:04:50 -0400 Subject: [PATCH 14/43] Feature: prep documentation, requirements, and gitignore --- .gitignore | 3 ++- README.md | 42 +++++++++++++++++++++++++++++++----------- requirements.txt | 1 + 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 6cbde3a..1c8c437 100644 --- a/.gitignore +++ b/.gitignore @@ -141,4 +141,5 @@ dmypy.json # User files output.csv venv8/ -quickstart.py \ No newline at end of file +quickstart.py +config.yaml \ No newline at end of file diff --git a/README.md b/README.md index 2ee4f65..b4af5b0 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,38 @@ # Linkedin EasyApply Bot Automate the application process on LinkedIn -# Usage -1. download all the files -2. if you want to use the GUI, input your preference in quickstart.py -3. Run "easyapplybot.py" -4. follow the steps through the GUI -5. GUI switch is at line 315 in easyapplybot.py -6. if GUI off add your data to line 332-337 in easyapplybot.py +Medium Write-up: https://medium.com/xplor8/how-to-apply-for-1-000-jobs-while-you-are-sleeping-da27edc3b703 +Video: https://www.youtube.com/watch?v=4R4E304fEAs -* Make sure you have chromedriver installed in the assets folder! +## Setup -more info here: https://medium.com/xplor8/how-to-apply-for-1-000-jobs-while-you-are-sleeping-da27edc3b703 +The run the bot install requirements +```bash +pip install -r requirements.txt +``` -Video: https://www.youtube.com/watch?v=4R4E304fEAs +Enter your username, password, and search settings into the `config.yaml` file + +```yaml +username: # Insert your username here +password: # Insert your password here + +positions: +- # Position you want to search for +- # Another position you want to search for +- # A third position you want to search for + +locations: +- # Location you want to search in +- # A second location you want to search in +``` +__NOTE: AFTER EDITING SAVE FILE, DO NOT COMMIT FILE__ + + +## Execute + +To execute the bot run the following in your terminal +``` +python3 easyapplybot.py +``` -** We have integrated this code as an API of https://github.com/socialbotspy/LinkedinPy ** diff --git a/requirements.txt b/requirements.txt index acd89fe..12a1aa2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,7 @@ python-dateutil==2.8.1 python3-xlib==0.15 PyTweening==1.0.3 pytz==2020.1 +PyYAML==5.3.1 requests==2.23.0 selenium==3.141.0 six==1.15.0 From ac659b9a0f5c06273f2271eaf423a9c376a553e7 Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 10 Jul 2020 17:08:31 -0400 Subject: [PATCH 15/43] Refactor: Remove files --- app.ico | Bin 32039 -> 0 bytes config.yaml | 11 --- loginGUI.py | 265 ---------------------------------------------------- 3 files changed, 276 deletions(-) delete mode 100644 app.ico delete mode 100644 config.yaml delete mode 100644 loginGUI.py diff --git a/app.ico b/app.ico deleted file mode 100644 index abe560bf6f587a61de220ce38a6c384600713c5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32039 zcmd?QWmHsQ+xNY}kw%7+AtXi`l%YF>Q9_WG7LYFKl5UXhkdT&^mIgsuy1PeOy5ZT^ zeLWxFweGc^ukU6}Z1ymFn0=nd`9FUD;{X6O026>h0dS)Q(9i+E1$+jB{dZsH5dgS? zzoVu7?|uXX0NNe{00Qyf{TfUFc#8u75)%L2cgF>Q2SNbw_5JTYoDu-sp8ePem z5(xll0ss)E^iCQdhXVXd0AE%Hsq&vk@CL;K&jm6yc{psODA4RPkqkBY!~d&uWzbf_OzeF3e_Su zQ2ZtT^0TvJ#ZHj7MdSN2uIinhm&}=qp5CtKw+%C_U(GiHV5u2*Qf+GP7pv>h2j!f> zi_A|t|L&WHtXfv9CQ7>4n(N%G+CQMK=XTl|B8?)aotuIb!1AaaOcLX_RYzsw#Dk~#KMyOsaIG>bVZ!q z+PJ>SFG;95Hid;VUKey8tSm+Fd@U1Zs1;c+d`Bz!1&E2viE#PPa2fK$YR3|hTP&(q z+Yq1=sx9gc=(@8>!5-mhh4BKx?36;j)a<(4()0U!${Vd3 z+_tY@zdDLtY|t+s<}-h>D_j$1JbXF@AiKhy4k1G9W2>^r`dqykSZ*x_fCYpC=up}< zQ9mNo_ynh_>)0}Z#qVb2lCs51Uc5(e6(OStQ&afd%#7xTR+odlhLCr7sFS;?f+Y{r z^CeezIh{z^Xc;UY;aS zH3RQqHbPnj zDis0+gSsiHY9LEbXUW>z;d(GPQL)YYdbV17mtK~KFdZ|Z5g}GnHCwvxx+EfgXWL=qYzX1sfbZOVd@lNi|MG*9Q79B!qFjUS($bPCoUdDgzG~Y$o$+0I z@^sQCT{o}oP8uKKC9)LjAJgXEo@YA?zr*X#bxKM~BH!eyvAQ4f60+n2epK3pq{4~3 z`(5s6T~=kLW@gd&)XQ__itpYjC@PZSJSH54ucbdS`OMYGF_I?3xyjWp=%e_(_TwQ# z@<7llnDd&Sr#Suwnx+++xoN*~1X08BpQ*Dyrrfg2`V|_k+N?Y@cQx}7s!uCw&*6i6 zy4QvCO;1ktbx<5W_W5&u0?=37M3{dN5i_qK{ptPu$xltyY?qo&v#>{5%svRJ3(Pj< z^12FfhSuN^SrJ?P7L))UHvT?h9qiLj5|h^>apwAx@>p8dSp~D9Mfwp@;aW5jJL!RK z7^x)vtGzAeQvBgZlm7!yy=%*}l%cZnzsw^F@Hp-N%%gz52QaciRR24V78C#2s+o{< zXE`oiENwff&f8Ba>2p+_<1 zY+^<2>>+PfyL=(*of9U6*IH!QF^rh7mC_v&6=88IxgGc2cUw+m3BqGE{(rl$0E)cmKs*f?3&_HC4~l9Gj}fP&zpc<^0)PNkt9s zRRca{YOi3=pfbtgt4Fki7blku(eH*5iEuHHhA>)009LWmV`4xm{iu1^bz|wk>gsI4 zy>nuEnqNQwO9E+$d>fb=$2KZddO{~BpN2)=DlD(Po5_Q9J?rkdPuF|$ zOfBu}^rp(}I#cxFTN==t;Kc;C%lNaQdF z+rwFlNtxc&FipE%MyKYIeG(Xbw)lc$v)YbGowc=g)4|=`+-DPVFw2XPD1o%b7Vb|LVaZd z!`Ndb^m1sA@d;(rOVnrQTdzC)MtiO36GJXDLhH<|RlmIdWm(0jOy}TQQ>-0Z>)%^r zPw$o&pv`pZVB6zb5fWa)Nm=|pmpMwcm7raaOqi_arTq|$__h5_@WOd3_1QQ*0Si75 zWY{}MO>FN)eKt8&uW*qd3AL9T`W;>RL?Vv*XV|Kx)M4js0oL<@&*()Y7

O0(UF*Nw~A`}0- z`VJu*hW0Q8*9?Vb`UM!*p)cp<%X?FNaiD zvZNQDH{N{~q=dsWF7(%9IDCx^!p#jo{>9Y1fdlyB)-Ae;4eCVRgg=?$KKFz9 zVurKjlmzJdau)M->jHHqI(7L8aVj`;I6;z7NPd3)Vav@YJ`X9mRM-AA3d&+#cKwEr zueIb@OthxbpNkGRF~M+u!^ypF|I~ZWo4H1DBK)B3R9a*^l(W&kz@P*kdcK7>hvz96_;oAJA#OTKgwO6 zFEw_)tqUBv-C8c6xemFwy1Kq);s3NCARu5qn4?kT-b05TKyrd@Fd$uI#S8|5FAO>C z)NR6i$J-;PWnawN+6w1>$P|u;!XO=zcBebm=NM4gx99jM@1vp3%4VO{sfmiE!`BRi z64+|!`R#TuppQTPOu68#hE|>EnoVDw`1tJZFDM;OkrJzyyf@=YeT4bM$>`mX;}6SgY=tHuDIw$yd-o3M}6&TRojvJ20Vpm;UFIj?Vdj z%`SKXkG+Gp;#31GyVym_5FUC?P zeGAg8maLBZ8g*qiqghRX&(S2TUIdz3f48-<5p_M#wXxYB$`B?F>zb`L8{a-%?F zYN|aI%2Izh_PKPO^{k4H4rTc7PfN{$qM{jn#&x#K?~r$QcS$CkC+wP91n-`*{P_N7 zpofd8BI=oFhS&b7uY^RP!?fH?To~Gj`N@v$hZcu?*Tbb2^^(k=++0F@d_rPknSI7W zLPDQEf9A8EvAa3n_wz%e>$29VDIKnD|83CKVPPvkqbVDLwDYPGD2yS(YHz2{moFSQ z%FVsGIHbu+xy)l*7hv7)=Chfbn3^i6EiWpv`1I+Mx%r6puz$WMEg2xe4SmEpF51tJ z4kxTVwBjf$%}h;Yk)_YHwP~30dH(OqV6(@?r)g)6GVRUA^1~Y>iC=yKL5G;`4<%;W zkD~;633IUdx~xy1jzxwqyauY+2jDpk4$r*SKUQ95DU(Bq`XH(kuAP#r?$q$ca<;$` z?T{iW>XZ^)2-P3nU`^sj5;F;*bj`fDX(Sx8NJvgv8ih$wPz#3|oC-Jj3Hs`9XT20J zrKj}?5jkuE|1A z0jH&AT!x|Lmyvi1j{(H~W0EOHoy;AYWmld@KqjAH7BrR|=j!$twa~k!DH0`vo=X1V z!I=5DiXpJ%th2#Sp7K#U_DEWAW+MpBXE5X`jbbC20eIoFH`*N z@54Q{`u)Rwia5r9ejcVbmJRP)uq0FU5C4}nn-@GT@jq)euhn;T0Kk>_ziYOZg|+S^ zsTk_z;UBT6SpH}l&zT%7h$4o9p93UFFptqyaaHwzyw z-Yhx}iGCfev$>=QxNF1KMaR9}3VG~((uv-7W$3qj`_CE0)H=5v%6#b?s^1sLeMxH8 z#)aq>Kt*sOPel~1B14fpH02E5NZNd)F+}ZM0v4FV`xZP5O8nrX`?l+z#JepJWrNz? z*^wWog-3m_ZCr~ayQG5~35akN>*_cR31Ffl+gEUr!Q*W|n4dsspN&ilQAFO~Wz?VV z&okwv_Zm~Xt@xGc)W0*SE)O=RXU!kcegqIE&bKqX7Jeb#l`9?XlCdpd*SH(r>Nv^E zt3pw}YdNMOlMArq)8&&$+I0w`(vLjFY_*=kUuxIuPF{xufCAc&Fib76OXBF=#~w%7MNN zX5Y_}=lK))lwD}h?T|JA$?A25M<}AmBCOHI{1hhyU+RrfW#wq> zwEmLE-@iwoPS|u1>_ja2Jlsu9O`Y_1)Z7um(}F>n>E|$6K&t+hh)|IbPPp>SN>v=+ znOUJadVuP((o?K3ECv_O^=Cv$e^^{>KTUVEk%3Ck<+khWD?f)$owbvb)5niFIXP=0 zYd3_gN67=W>)&^VzfPe08Jj)NC{;AB5!9TkMQy;_8gQM1sxm06!v^x>q-Q1sE za$8QhDt1KME`L}aaoR7i8rybKP>4)$a6uRY?a*h_+xz>EyGaPSFU=mQD&AK=LX+=! zQ+1_x=w#49ETSj@MW%%>NCbxF=59}a@USsA|Gqyn%71@;@XU&+Ec`@jd$6B$Aj|7~ zR+`4k2~7GbHRV~Hm&W0memo{z>oEW@f;9ivbMD;gF4V$9eyup{wya5wpB<{HC4qxZ zYV@7QPZ0p91f}$zwgfZ05r z3-zrz7vaFtN7BMXJch5Y-Fm0#VXdAH|E{FG+W(=JwY~4g=i9IgyW0p# zk&9)YHjA|K-L!)3o7TgdS8*7>Z+(G&=V54TTX~(3B}THoaVbii5%}q@HCeC0@dTr1 z|2ArJ^5T=Gif1vLeL8=Q|3yXfessvO&~odMp|0h)wLew9v>eM z3i?^=^H^zWateyiw?{n}d$S<4JMSV^tf{M8az|w}=temXeSvTKCwoLk?6WA;6(%qa zuqh2UwvKIFYFjj#dEK>8CP6;aqATQuQR&-S6o38~{3heV4GJertf(1lJ+z&xvA8%` zJaY52ww7n9bwmx3eQ?VL^JuFV`6BMe8&U+KfuZ26Mh#`GrxO$628M*H^gDEJ;DvMW zc`?J_X3Q{$U`|zZi6xCYa$G_fT#AH>ca%V9N9kBn<;&kIYBTH6oGCxhI>6O)s* zH8l&LcguFC%HGlwC7IYPHD7gL(jdFkSZh76j>+^rj~Y;!xR(hVO!?_eC-_vWY)lyC z*Jqek-Y2V(Z4c%GUkkTIOTS0F{6d+>JVRzdK|#%z&&k8%^lzoZWFU!`mv_GIHL^js zyrg7zFqKw^dU6s3zAi(G48k@n`kuo7RE#&paC1g{POq_G8;b84|xaCzS6lxy;+JX_P~N9{W%k30e*a=eWH- z5f77<1TB%|dwRA{OD^%?=kht7H(|+_A19GCFVmZTFVe2lU|-60Od~Yn=ppJE2~AhSF;UCdVi}O(h`(lhn;D3<99exQhv3x+D#A> zcg1-bPI~lv_c=O}MAsMIF8P$ED_}((?>w!)$4nkM9=#G_4=Gzea-*j>GTVIT_-fS? z(fw-`Vf;2HoWh?j7=j5%kpy?fkz#f_m}a-f&~bf;;2E!pZ74U}EM>DgfSh3(D)}UA zYDi-;Cew>5TJ&kzGScLIdzRY}yrG70vlJjBarXPvZF6|Jk}%Z;L-*a^k?adG2l+x{ zx}*M8@*e^q)R!|ew1*Bq5Wva|e&!>l%Y}p>1Obs-7fdB-3ni{+#>$Kj(`4(EJ~xMB z&+#M>`Pk4z72jTe?JX{wCOdDmUc6LplJ1v0;|$}sxcJ1-7z|&*?qTxVP%Nld{(8ZP zoa1|W^8N$X)FG!tv&Od*pJQ+%gJ6L z{VCq7qE4kcYzUBda^4ApUbYrTcSNo>4{tm^<7DzK-+gIgy|;b6X7=_wgD`KJBOVLT zuF-$t*<*k5jye<`D>KrqDrc4NwRzLe(T=$229|Jr zt@-L&o$j0S?-$49cQ*|@fe$^GjSoJipep^*ZtJbN!Y|JL?<>vI%RdDF5v>pC%bOda zOtXc@|BLXp0+0Lt7u5aF#3MC3TMo7)j=`^ViDDA3Mc5YOv(^0C#m&wOs7Xh`{##Q#F!5wtQ0cd2=bibd& zC}l6hB5Xw2cMV&bCcSRXTTr*>(a-xFO6IZZU!)e_PdV2&9&URm`dlwKdZR9GGOTKB zD8H~uSZA~SE7FKH$Yv#CS9|RrR4nsDi4^;3F}y=konG_%7YMHU|J%WrR{qF5>ih)o zy2Yrxbjq*Ff8q4__)92WCsfjIND!5YvPi=K>TI5`NlqV>9uuYC-=P`2vMk;G?BnCZ zOhQ3UzP-C!QdZ{maCgDR%39vAMKOF*g~LHRz5of68Rt3s+DRhadj#KAKX3)G44y2< z_ySN_EDNovaE{i?UHyl~?CjNczeivU2ibL<&Gf{CW>T%ozIKm2%xHWC3@FWyS5v-z zvq+QrpG{KEIKpuV_6VSDzeR_fCES@(dqNHIYRyJTPX@k8TXOmE%s~39yd?8m=KT1h_D!+ z>6NF+W@3Q}_Heu^9NwK$V3F1>nB}denC=*<7-oV?NDe0AU*+<3rxR7szm;1B8=9v* z4MV1PrF?}^QvT)b6?JaTNOuz9^eY!7!Ojr*+*PNVWWv=Q7xk5p2u;58=T316`v8&T zAXAqkR`JkH?evG;eVXs(AJoVd(rZf5@FGXsFE3|`==k5i&)II2E{~DCTTzhMcz|Vvrm>}`R)f~6)F%wZ^gTD*!=+u@pN7W{sYm&0o$2m<#Ds9u z#Ad{*)BU$!)ARE;J!CE%IxaySvu#es_sbR(4Dm5&P+4qbJHoc`4N7ElIUJQBSlBW$ z8`sO&^kzt+x9=nl0)aYQn{Rr1_mKM8>gl&Q9pzk{x9Jmw@qkn&Dj*am-6Cx~uS&_3 zi+uP5XTz-ypNJ6ETwl+>j>DhmmNR`J8I3^!;L!4(7iBULe2bo(J%}-lV&@6}dF>C8 zyi-u2U7G-cBz&!}HQGCBJa_J5hw?l&7G5{-%vl^|rxJ@ND7SV1vY}}>aU0iWdNk*+iU(O=|xqN zNhW9K=l5%5Z2~TPFmUw;lVfmWWo7jg%W5>uPpGqb%d&oS`PH_y!SqiW!`q~vusC1FOgwgs)n3kjN|=|*=!=Q&2gViAv(pNpcbOABAKt%rnN-(!q2Ibt#csl9 zH>6HRjs;Qq&q~h9!XgL`eGzI~YwL8I5}#5BD18i8^5W^qIuank!vIs&Xll{pOYyr) zGEfSMA(9Jw@XC)YR4S@$jfb+m>g8n+Z;LvMyGBEho)YMd0cTRj3)_^7H?eTgtG5d#T43X0t$XL9A zxxAUfD;7AHL}_uTXxqca!)(iicm?M`$pD@d_^x7E!~e5J zWO#5icuMf9TUE=g|KxA0v;srePORf6t)%5cTU*gL6hcZ(=X)340 z34)H=r-NeJzB3pS?x9Ob~ZsT5ULKzY3v_5_Zlm?Z7B+*tIE$ljPjD!7*re@K_k ztMNV~(s|=gy_IpJKRru^3k(%(IOt%EU@@_<*q*m-TdaC$xoP#jNMozG{n64=QZjIO zVU-pfnkwI2w{Ogq`mS((ZjQG8>&p-aC=b5sx`@?SJCyGE6h~yjr98Spbs7_z=lvH= z+C1FYMK_cbmWmuxMv~;<=>xMYOXupp*1H>my4ndJ)6Q1zy|&x>ww9{OY_KrH$w^I% zkAZ;!BpzT2uhr)5rLUibawnT1K!?J)hE^7iU}(O~Tm1AH!eto?;aE_<+L(^`sBk<~ zEeAayA!}SXE=I>#IH~$VM$Q8EMqmGdvZC#L`eD2ECe!ootlq=t*p*-W=eq3!>f+%) zC;XGfJFV~IWn}am0O#;}BtNy7r`yMmk_si_=Nr{nM8*pd2|T=%8OpN3E!a$E{Dq$R zQY>aX`{r zbp?e?)Tg-`*(;&(?V5zKlV)=sp^H}YDH-$_ImRHSD(%#SK#b*i(YdT2XiDkAE1%<_ zM~TtF>3M}mS2n)myd>~_fv_$lSL%fGqO%?yk})0|f_~U?k=cEf#x^}uq~<&$;Jh=D zt=iclx(H?yKkv@xxlB2`&giG;5q^zSv6Hco#wn(gUI-7mlj4w005aF z&$tYxkWO0X4dQ3#^o>IABKnT5uY+S_R}ByX2*eLCmj#*8Or00_-GXMq zEFuDzN)~IySXvq`_C&-ZIi}0&dfda~;nM}3hD;=#Sugnx-aO{IPS(=2=*Qflr~L({ z=^m2oT2*j zM?Vhe0CAEuwYxFsL6q-e1ayFLxCreUeV*3H+Svoq-;f&k|mmlJ>$3Kh09)FFG>2?>OdPh&BQ~xO=CB+R~X>@dS4#6B5M4PPB zJhGo%rpu+Iqu7gz8a`e*umqZDd0ZUaZ7cd{YiVg|YvTafO3bP9@72@>?*^-};A|Wf z8@dCi@Fa-HHyUagYB6W)s>j5>`P{0+L3(ZD@v@r|caR=Q0Nq$qg(wLrTKH}e%$;2q z>_f_T3oJ5)oaLOuBPu0m?f$j<&E>z9>Yycbc6JuM+KBHkKtQqb#w|!U%q=a8i;7a4 z9O-UU66xO>RVoSKscea$OCy2KiP9ey7z*6xX>_WnUZ`pEG;C{OCL#y#l~HIYz07Bo z#yAlWh!t<>(y<=K6dbvUc<;?{&e!30{@vLDQ7Li*3^DE?tGGJeWP4u+3;wpN z$aMh_l|)o3F{`PnJ_5e-*^D{g{Sw6m5Zkl^vl!68b-&(mF}x>p5d=hTeS;f4Re9Va zMBFL@M#JQJ=cr_k#rLX8syx~VS)cDA<( zC7Dsx31iNSAUd3_E3=pb9WMeJF{;Qn>OWYB!e;99^!0tl75MYVx7i&(kFCbN6~y|S z$rJ?1#3v-|jpr}>GWq&Wa%}Js5g_fkzhQ?8jih*l4#!EI)2Jx_7|M-rC;N@tdON&6 z^WC>@Bv*Cmn9uO!z^4!Dm8@IQnnG{^|2HbB(ZMPeG&HvtQg=)@@#f^cVoQ&=O}RD` zJ3vtIOw4npZr=a_i|!E}9x&#L;Ow#gSgGmm?k+;j!NFl`VWE6TxJKnyjWkatISt8-Y5B(jeqG`+Ht#_Bu0ml zXI2z4Kr=%^cc&|C&CT8ZgJCc*CVrP-6^w7QRWz$8Ts`aA-rlZce=A4=vT^89i1=;x zy1=mL#X@%^2~ULdiOaY|ExCO}v%vO`R^G zmoeeWq<4MOSHmPKdsJh9HSFWH|8V`$sEbQf)<40OO6GBNXdu0!N-s}qllz(D#RnUJ z(>)WLpC|_E4+KIhHLXDkUcdCY#1K|I;7HYDPnC#=0Z3x$O@q*bHIV`a12tZ>*!ABy zhPVH5A5d97_o-%?qdW@ELEddXpsflj!V6+Wk=XIBzI{xQVVUnj_v!Q2J?z7(TJ}OEH0BI1qQw@nBTziWjPJO4+BICjS(3=w%4fG=Lew**l)h^$k z*Mlhk#C}@1i8TNxr1mCl}2TO1zxJ$PQ%}+XVMVg3Vqe;ViD#r`F*3~$508> zLcG(%HQRlD*~NbRx=7({An=9O@7Q*y5j0ROt*o-VueU>Jye}j9msZjb7n}H>hJ$pl zp&Z0t?qK4_q2H?e_)Q>ma&mIj?(*_zeJ7^Q=Haij_*|QhkE*Kb>(@c+0$}Aux?_*M zspO#4zmN0@8;t&7>2V^%crK-a8Ov55dirMqSKYR~{dJodR0sq5@JT`l3`l62IBAxl zcbL|51w)Q3%kJ(jxe@qIU>x}V{W~)Y%P7x_L^(77o|Kftin_l(+a){#$*KFzem(fo zo0}U!6oJS;39u}|!{Y{@e*wWA6PLA;pT0zd1}E}O=$f5HK7G~YV6T?l@q%q&AZKeN zFl2Tpyw6zqwURrAmlzEw#=-x4Rn+7ByPXI7)0-{+wxU5%x1py(rPA@qLl}UPuUv>}#LcuR@ z5uEj1U68@vv_H!jzh9$V-XndnEQY-{CXF7hJKDFgr=PR(&OSTa6knRt{Fn-v<))JL z#j+}qs?{Q;3f2vkeJ>1%5R%Vo;bXkn@RsyPtr^j z4O`ads-nziW*Qfb7>54KKwIqWy{qD{**pGe!}4t^^wNmOrqgoHIjkYoZEX*U8XsmG zol9)yGf_d3biBoOL+fE-lV_tQ8tf+`iqkxF`HkL9jg8=3ksqg`ruIIcHH&^GT(H+e z%J!aJzx5VW=bpB<8~_p?YgN(WGLZBF98s)I?=nPtLhbob7h^IqA47+!1@1zUyt#MG zR9&E42)RmY@ zhvAZHfEu&QlT zAV6q}5Moeto5@ZuIS((S)8l;49R$imVMtK1fFr@<5ASZyXxwDU`>yq)j*iuu`0f4O zw}s<<#tz2D$Z2Q5POm;Yn~o?g%XjDXZC+=S8fYsc*-~J6 z;rQwZ)g3L0%JFwN#}~D9j?G5QVfy?e(OG)*k9(cj*eR($umM$@kulhh^esFhNA1N9D=M-Nr8CsvVQK|r`;Q@_YJewP@sqUL}1yMs@vxUo~#l|D7~Sv(vt zKz#H7cm|}O0PV0xgI4CVEW+B_pk@T~@Y(_AMX)jm4Dw~o&AR#JZ{V@rlWaN!R6BcO z`|efErE2^j<{59mmJH?aJ_-kQ9x*ZTs@>1*>{ko|uweUnn|eL)djOQ)zpr!HlT;;(`NbT;!~KC!;O&h##QrqZaV%4EQL{=Hr% zG$*IodOxlEQHc27o)~Yed{R$Nfnjk>rncs{?qxTT51`7^(%Key2bw`5)NQ-vO$irJ zKuzY*a|%y*9)lxMgyk`zLF@OWpqU)3@4Job0w6MTU2->bcIGvbpY<9y$xBdABrUL@ z7Cl!U-fqo~9UY77%_-(uZG?QIE&tG>t+04~ef>sS+Ng4dto7gH%{vXvsx*z49x>0y z@)=}Q-=AC+E@jaNCBdY+HEChypFWvJG?bTYF==h)C5*=PI!~9DX{jyFE;^dp&B)G^ znkd)4l`G20h+2oB``BQ`7pA~@41WSw<~A_Y55ER8t~bFwO$+7DZC&;5wU& z%!9O>YoxlooXWHZJ(2Pd*0l4aTt=#u>rgJ0ATBDbShp2E!XqXoL=heBqar2gHEVI2 zb=rjL)skUU%|v;95qbpySZ?oE#^y|_y?Px*dw*+>2ZnCf>~oPp#b*tE#z@j~;TdiA zCw3Uf3?61c93tpdK19`X3uHlkWyWiYi(iXnDfS2HJS*wC+VnUHYK9v`yq1H1+dgTo zVURAg(h?hTkCLAp(6M|Skf1^yaN@G0>poGy=@k;B7zq`n8{4kwZwwSf#gdg%zSz^FO(AGXf2Ji6Z*y=Q+5Nce$vXYFfqmrB2d+6l1l=znWTae zP#&H{H^CE|v$2sO_jtNXe9;oN6<JIs`MB{i(++u1ZCn{TP8T)kr!sl%?PD)i7s?l zQ@!svs<~*A@(P`;e`^r>EVqwJ)&=PA+!2U6I2rch{PS8lO5JdD%p5@tiT&j`pFoeE z@vxpHv~m_N7!?RmU}h+8DLYc7{HuFt(46 z{%*Q_W7-!35V>D_ct20PPtb7{!(W~T01`aQR@e>#wB*zB1A()z4hQt|-cSEI(Q+)^ zhd}hkhrjN*&0?d$fVJlO_VlODLKz=O4YP8{!}@;}wbx)E9!kEN5z?p9-A`U?zDAym zNt*RShw3{{-*DH>dQ|fX4F{t>p_&x~dF~hykSJORjF}&)LQP?*z9vbetERW;ymLyr zIr`usy_f@awNd=cESf-_-jsc+gp)v&Zyc9GZ)3TxQbq1j$j{e67~jQk)OY-i&&xq7 zLy3u}fKM-HE}w8GFEvjqFqmFJE`b_{OiJkfVlIr-a0@*F`77A)Wdv`oLoKKcKlt2< zyWH7dOdrf#f1Y@lVWaU(fe5d2c0-vsU~o7QQi7f#Hi14UK?O<(r!}IO{@LwUaDXo8 zjvMPpUB$3DK@(2^nAvvrK*^2Br)yPZY5zS&_s&}8!P7%Iquojhsw2eWs$?Q~dCOAy zUE~B#e8d>wdehQx-ei8>VaXC7nb5Sg-}}1P0dIms zf#+*$uA(#uTM_YR1N@H>^> z&Zv939tTInhZ`_bk}*4Kzbk(#0n`q96t5)23a2XYILBtRC~rsTB#q57r441K;y807 zhaD&53_E$87Dsp#jMzzVrlzMkJ~X>!9(7pz7av4$CSnL(ecq`ksy$#OaUQZ0y?LKp zE&M!P`8>y*qE61ZVWlaW6dbXMroz`|8lS|3_dL_&&6v`R?2dET4(VNAeyAH@Vs7;1 zZ74nbdsn3N|7PXut2-=f5qLFsJ>JK6fv1R}o|1IH|G zte3Z6A)(-s*U{0@blBR|*0zj@NaZ#gO65j@8NDIqyP$3aUkFZMHYyAz#60Efys#p9$wyOB5orm%^+o*n3zCxgH<5HM%{Sbnhfxm z(1b|`zLzKwiTqntIadTso-rQmRRHOM5-vX*dN|n3DHBx~yzh}yXZQY4608esO}Km+^|;4mCQLZiWxW>CUH0$vT^*bRve zgX!%!Po!g1X!&kbL*o)E@2(2$_17JeTaPr=9Z?fS7?z>-nv8;=WvI%=3f!tUhD3 z_7mM;q?+9<7E3;qUN(*%KF$LQVzA)Ls4}H|mn>kCi}N92ETjG;TI9HQG&cZDqarLC z(ldsQiKz5eRruonY{vKH#f1yR2ogGf~35+vnhRpeN}HI4`?l%CcB ztQkK0%Gs@Sr|~V7h^eO~G{}_{=7vrePX9vrT;NCCEfV-A!jV;N({m)Eh?6al9ms%6 zx6)Nbk90f><$T(U8lM%R9iDYz%$nx;L;8!9%WOVXH`DO|hc48?T{wPVyYW-gFPD5$ zyfiaU>j0tIe`6aQp?;*)4j z#z41!9r_!`S3%)V1*5Fy`OsyJYtyKCPQT|*KOP+YCMG0gIWI`$vNYhpq^d)StMyct zF^@J(5>6=R7w1ba2Zz(g0dZ(Xy-*ltaIXNDaRePFJw{gy=bP7YvfaL%%wOIZgobv( z=+L>0v3h;!435xNDhqvmcUS;guJIVO#i98+BjG4E5k)x`S#&gCBt5?GBMUqMfABqY zR(J#D2lK6&)_0f%hna}jvxcGtGCL&318uC{O`TX9+81oc{Bh!&<_|7w@o+hutwvq% zRuBYrMmS1MH+07yHHR{eyG!-Rj9A+4rf^@3O@EmG;eB@v!oaHP>TyH_cJ0Oq1@S*c zK@A$8+oRakShn)!{>o?FDdjpq>C#|<#G%V`%)!5QBSYLwWP>K$%Cps{3uT>`$sN*r<*BJo#cr{T<}julo}< zY!@!)cE9Qq1WAGcCBL`-aoG6PL#WL385lBe4%>W&(i=JqQtYDupbN;{e)pi!CbE|% z_yd5^K0-2^YXA@lBAKVIe3{!JCJ)RM)MnJ}obew$$t4Qya?`y_;zT6@;JdexVZ6P# zIqOZ?H_CQE=4k~k8d{#nIYzgH9Dp6V@{^X{?n#6n3a#%~1D#*@OYBNY(=IVCCZ594 z<94jn#^*sM7?~dDvq}QZ*0WV0RL_QM9?K*!>%?Vj&ixY%8t)U&zp`0wjkM2jd|@@; zIOHE}n0wFBl#Y6WDKQOT`$3!i?Uk>wew>zAhcHna+!n4c#w?#zUnsK5p^ zKR}myeK8{g6CQROa*bZzPxkYTphq;+Iy+OKa&vRR#UP?GzE;A-e7CWDR|o*hl3QC_ z{rvp0v$H{x)MuPHQfS~=5Z?B~<(Pzqqk^=H4t%#JcWsvX#WR;pF~^o4c}xE6MI zzX6oyG)PTWA?KKILPD8-Tylw;UK&iiqfH2s!|z z9T};AVsbIrRbk_=@O4QhB9fPnC*l2v+Dr^|zZQ=Rkhtf9?tv>66A8}iTxHT{y~bSI zPIEkez*aJlsQpjnd!B{1EEOwe;krQ7I#@8Ab$?n2R<(m=9+|?f<=U~!_nU6$_k?JK zvT^cX2r(}<6p&~e;Vmp^&xs70C*ptps&RbI(#iAoJP-+#xunIbmnr=r)V+N5#KPEEm8dr}05QYObH!|0N*U{s!#TBH; z`1tsaTZ4VZTvwi%p>N*20l(%Xb}cTOIUZ8sZX-f@4B&eWa}LpCz3KhK!z?kc&l(!Z zHuE5svzjj7-`PnM^#F&4ZDCKP|5>t(9MZpZS5`8ZtVX=GkrtB2PDjVYLZtzfr9%o( zeE?Mn=%6V~JA)($+`u+saGibD)3@mW_T+-vPBoT@lts;c;P~HjlCq~J30QPgJ>)PL z0xS(U1Iw|8Pr&vpF&>^8>shX8=Vd;eceK`ouY!7Uupwdv)OazR#_4pv3IC_PuZoJY zd*dB&(4hwe1U{s5K)SmG1{`S+1wleeq@_z5ksj%kPH90(k(Q1T5s;E@kY>*2e|2up z{aMTP-MC!eJNw;xKhN{4m*Tw&c1iAfX&k(gu>y^Ie9to;7m1+mDz+T-b2k~kKHivo zNA+OLJyYPNkpUx|6Dz~}7BGs`(aOvptlmdQ7duF-f+h~=zQ7OEC-V2r`^CUC>Ws{vbjnIu|+w<*7|@djHL`|T9$0)H%m z=xt9>?uD?yanK?gNUH>noHz(M&#{XD|lYhV1Zt;6z(yeeM?zbym zuZeMgD0QqZfu%?aspT0E9(*P0bGo+xc>DX3ZZ=L%{E20Ly^9>H9+eYy zKfQ2zejYof!RIhtQ&3d}#t*K@;!8n9Y++TEBRHe`p2nDCFqA#cB=GdUF8Rzv>Hf1% zFct#Gr(t12O71NT{3qvn9L>!aDJ0H|8cf_FyincsbDj@qm4Fp~g)evgP{4(Zz}3WD zyiPuB8eYz}&S%flu|N1hCW2b!$o;+QaK+C?MMqH%} zyi69dv_{&y(T=js@v8PDY6eN9<6`Bus;!5e}rsQ8DKZrW^rcYgXf zsL~c956Q|um_Wr`o*TW#43}Q?mEDm$T#Koj5VyePofFSaO1S01h!T6_=-2wNkwwHo zhoG}Y+?wOC%D3b}xVMRW-iv6N8()DOwq;5Ds~JFKdYskqd`kOOWehZg$V9}%T!#YA z223lBn{*1qGmg0{datcKx9s&KVEFKSLSh`xgC#>=huAM!MH`&LZ|Kl$NXYX|oB;2QUUYt)mxMifzpVT}Cg`g1 z_F30G(-4Al{R|vLp3qY0MitUFFxpv+L$ zT#v2{GN-Rb=h_uIRp)4lFo(qD{mIi^KW^d4{sS#*IQo1P)h}ul9jcgnLkdgHpFKvP z?OweSM!A9l2pCW;J5$?6Hgcb=BI&WH0K4rNWoE~geH8>+r%%NC$uDYAS z<>b3i;@wYuiy3ef@XvtX0*0W&wv$%Jo!_8|=BFS4v?M5{t(OM_fWlE8A0sz@x|tNM zXI#f)Z)Ro&+)ujN(Z)1b@g-X8Xv4N^7iE{$lOGPREAZ9x)bHuynllzW`DuJOMC0rt zJ;Y1DUiRz8Y^%d1IQy+*$4vBDrzZ3NRvx0!_rEZod_VLyF3|S{N0eS$iR?Uf+WE)jc%wDlcvEb^1LWwolrz z0RbmlLIK#pfa0pM8TU^`Z6)iv4A`HjU9VW%v_}?T8I*#90ewso-r4_GpOSU@RRbtF zYoi~5YV-VP9fd*xP8PIHoO8~e)$AUej=f=nH5`^Lg$;)ONwxvAbxsj>@g&a3E zQS*(U(WME9zecJ8t~QmzNl*`U?5wV;SQjkq9%@fkZc5u3B<3ss z)+4xDlGJ+V;X#QZT@S$O{Q+McLoah)qIqS@PklZWJUk^jF=S&a94&8}AnQ8I)>xHc z?Sreu5;(w{d5dURgqk}3ZpdmKF zNaRvsj~F3;_D+FzVGQvXjzgXsv#h?`C&Q9so3O_GE|`(b3-3H~EbUp|)5y04hSI~p z%&@(_el`eB^;c5=PfbmDu;x2GX`LbqIW!YiDH6|4{{16*pGtct4z{;Z)}Ae6KXu>Z zckV9(`!p5hg1AU^g@?-Z%w_lV^Io`L@}2qeX4lFLSNO6~B&3wcw0pQ&%yU}&NL0>; zZme61TOWEp)2J&`EginMr2KqK~hrwBxVhkwrr5aV%cdhbj+4%z9t(VZ}7J z&j+)447Ycn5Jx8*$ADz5Fp5Y`ZUcHfY7f*zW`~WQSD1E{U3+Yl#N=nHxG@&nApePO@BIl z>+xKhT^BmuX$|XpFqJKL_Knp4i^Id(e}Ayoj*e~BE0gY3bM6?9SCdoaJni7IUjcjL zs4By3lWP%Nv7n~8@(XAvZ;lv1!~qyPUPn-rq8AgjY|0!@J!&LMfvDrGpkRf$XPZF*_j~jNHc!z#%N8)Ji}7m;riY*e=5XS%-naEzq|%>W4$>;Wdj0w~1mpf6Ev@wM z=JvMV3fl1Hj})?ZU4@Hw9F^_RKxJlIHL+QEFh5yu)P-rTUgVFk{gUv;ZSl0r^vOdz zp1XQcWMpwXzwIoEa^(`QO=EYjN6Tb*Y`ja6+Ye#5$|J(>L*zriOR(%q08*Nrxz=>o z>&16`JF{Or_7}SjSBD*6zc!FK0Wg5!BOSJt%1!6@a`k^pfF$cTa592M_??PHr1Ii5 z!#s9pG9V}Qsf+N}ck8D>h@9952E$#j7lHZ^_|j{6>}_m-SibC^1K6*nMLAEehR;_X z^I(${gg;Pgq#+~zShjwJMsTJU1lVsc^mve5omeIR7H_wDnc1gpI2FtBJu7!Kyph;{ zck))asxdtgEFp2=sQFW1G)*DCB0O-@8_cR6v+fv7+rN5CK^l^9pg_CP&H+z$MRoOl zX<6lsig;&76jdw!IwXW*3~kT>G;&xVcy#Rq@-*MWLAi_RVJS(;LXRQp`}bG6raysE zI40(LT<&Td*q`-1^ibn^1ZF_c!10m18W{A1X)~j|SSc&o3#|la238NP-w-j&E3%kx z(KpDQm`W)QZ>3}4I}t`?@7d^fJ8a&Zn29F4Du^BD{8dNfe>Jvz>4;AxKn=@@QzS6K zG`#j(su|$D*f4DO2D-l8-%Yyvorf)d|NaG=s~jkCoawA$IK`rVMJ|c=n;Za3a?9!B zJ5UjUbaM0O&lF~cl2qQaOQ0Uc%+8wV>Vo2~=K*YzXcn#YBODnX$T6UM-OuFEC4X$CV3S^ z>MQT=$+ZHf4%-iAxHQ95nv_DH=s!n1qb38z6+jL=RAAx8i!|E-y;Nwv9~yP43V%P9 zSCIZqa;dYJIt_x0j0h|=ko_2^)?A|(=SHThiK9R+FvRl!0zr@kfLs|rKRnvP;mEam$Kgl-b<6N!H*4ik3w`Bv^8rK(bV7@s zc?bg6f5zC!Kem1Z;t}+Q?{z=X)g1>p8Tv7NWrw%F`Z@SM1n(T zYaSs_6qtLiAYvE8l+?Y+n55HRxFMw%;RI)rFEOs5NYuBZVWtP`|KMF|wiXwsp?BKA z3h$RKLG(fqktN36-6Q~%c|hN}o2Y`}rJVWIy4%x9tJ0@Ht0y^p2&{u?LqkKgJO8+I zYc9EwS#VsoPkp>G`Lff<^g&8X;t!~w#jj$QGNDw$Puch(I8a+1f;|{3NEg9f4um~| zSkU3nl!(N(NK6tAW{El~ZW9Q+f^vD9vht9p-;W3~^7^JQ=0Wj(fiDuSt@97ndW9$Yi~*9Ll9o^ed3+U7B`cy5zltS4^&z zbOVvfL(cc1I5|lEl&~~tT9g5CD1=Wy-pK^WjXU|(iJ2mMg;52#E<^gtk8OPl^0^+4 zNVxtDQDmaLhXWZtT--ku8kSCc#13BAgw!NK*1*c*{^zG3QR6>WTtJWC1_Wc^Y8EJX z@DqU!P*jg}yN#w^Mv^D8^5Qy$6;&89Ga)0)qn=nnEs5%uc37C5bxg? z;4)V(x}DxwS)W##5mK0)FCcJGZPUUcQWxha_PGvH>-0PjUnf1Y}gOf7tBzk z@5xkXvgiakOjT`&nvdiz{cd0=!b__{2nUK}HnZ`4)mL}&qvV1iw(abYxb^8z@`H+d zk0=E%C-MoqD0`ff7(AD{K}5vW?%|oQiAiSEi=;6{BZ1N~gE5eva6>w4Wg^hpKvF9X z3}?lCE>E^##Wg6P$Hfimz_ecS!vRkFly** zZLOF#Qr9a#U-24=j3&V6LI*VA!>Adp)jAL*tQ?Fay)=u;_fNF9KLplBwK-%^J{@-I z-by-LM-QI(n^kwGa!bE=et*lrxF8&&W|t>P7RPO=)O$W1{^8BkGt+0a&j|5CEjOfm zx+^)j*wm5H3e|E!Q9ta`3d0(%Kpm!~qXS2)v$JzWl7ZqPT7yepNX}{wQOz0xgW?uE z^0snFqLVDqEB*L!sm%0h1K{jFK0fVxp?q{Q-oUrC4mzVOpPe({E&c4aK9<-zvLO$O zl^k#jnR>0Dfl34DT}`9A%-LrD92KjvYv)F&&5rc5IITRci4y&~#PGOf&{EbES8@5x zuj}6;4)d)J`222oHpWWd`^9|^oXK$9huDI+muJgX1I}h^TcbZZ2PvyrKM}3kX<+5b zXARVuVLFyd?5nG<_xo3yZ2~G0BixBjwct;3_eJTnU_Ukwn(iGR@oHvgZB72WrC^D% z-$oZO{0*$U{=5(d4o|=hkW-KZG~)ngvyaz1>`C9fsm%bN#pmwybZFJ?uWFxTt%Wy6Isnv=HuvaUHp`;-=g27CX*_ceFMN3a_uMsY@GY ztw&cBCD1>EJyL4v32t!Uv{+kP18weXkAd47U6_Lq*#)wn zqHO=Cm}47Z+IL;w7MvtA4GJr`%`#g&g8I%a;WiWm5<;BuBa5Vj{&xi(m=!DyPV#Ul;gu3K|k3~ zIV-mapGM?&57TZx4(!%aJ6jldcF&aD{r>JFDq^VCF<^yh*WQ3Uay-guVmvme&az1Ir>@!=#+7pbhr-DoK|Jw9Fw=y^5Pyt-lhvM}yv z0hBoUkYisqrU?L&h8`ClAXwx2-pf6E)0chaB1P-T{?zp=mZRGw0EZEn4#NHJ_W;7? ztTEuC@uq(UrzW9tvmZ*P2(&^6>R``75FpjGVma46)jk- zKh{|iVrX@_6fF>j#vgE{f`+K!wI3t}39%+L^4OgBJDw2#mG0J}iq1g7wI9xFsdILV zsxJecpBVAhJgnpebroO@o832!F%FK7hPb=^+*KK}e$AWBc7GIsh26$RE5?2$TX%Yk zV_XqW0}Mv2Q`~$9wvT~XgNTb6AFf{-^=i*)aDdD&< z7zL&(o}B>CK5%lz0&WkS&7wTB&;wG5)h%fTpBTK=(Ike4YSR=XCQ{tjBi*u8!fM(D zi!#O6MV$C09qnU_y%L7G&o~o?8vx9B@&(vLLrPg2c{HpL<)-az;DYY{&yi;eFUFK}F7(4CgU8}9>Va*`{RmLz=jN9xDsb?$8~n1G-lISeX$J{o&t2w|Y1 z@d92VfOe11&c2TT$cLHPA8@yOX&h);eXTz9SkHthhvtKzF6G*Iu4ElPFMAk% zd8R5F4k}nvOk}qhaPw)|k;upFN zOrGrkrsEknkk%y5APH3j;6EI;)=VGUlkK?}S)h?zihN3$A;DEIm_7)2{l9--_BfN= zmt^fXqgG*r-`KS5jix|LJ^uUmCA!MDptkl9cx-+C1l=J4?oJrgygEhlt_j9r=62xt zXA8tJ*0~!ItChLL$cU8JvC9yE;xF#m>^KQ#*U3PN@okbP(j4O9kVIDez$I29DrLw7 zB&U}5Hx217b+3)&3ADD&~$uk0I!d&$D zFMxA^?()VjgSITRN&C=e3B&*gXDv^w(8r}2a~nAg4uBT55%vbQ6tKuEE=_>oodqI- z8z)*xNo1F~T}|@~>RRhzlH0++)*h+M3_@QW^|2Pfl=1sA)|Cpz$hMww!X_m5Yvgb( zJ@`wwYh=rv((msqq;pKCsjesVw;8tVSe)2wA5DfDwguq6t8N0{Pf#6;piNMs9MDGm zVp!Q$o#=noPd(y(1=xgelKZ$o6a0{gji7sJ-$~rq)U;~TTT^p51pmiI6^Kp)*GPe+ z807*#N+|Ua;AwPI?}0}G)=H{(;1>+0i|vB#b2)d?c8?$w)KEQcQ#S9(GeAp#01vRa zN$cI+Dsu=lb7@#~=i-}%&v_3WzuO3_-EFD?4b_7=T`AS9UvG|- z2G0FYzYaX`8$*pm!r-eIylY)}uBvcqzC11$8|PJ3uk^#Vv7ty#v^qK=Gbuvn?FfG~ zptL7cK0W|(HhzBKk&70x$sLv5)<$x5-1#Xm9>a%VLR%*1IW~Zyi7p~D2q{?0 zOZ3QU>e0qK4Mi9Qx$q~43t&U_0#-45>ac!<7I(=3HMi;?{5gg0pB)|Yb8lpjhE?9G zqM3cGhlREY(gIW!y&NHHJ39uGscZv4N0XJ6brT5)FcG`YPORi0(RUam$s3kGi3jBi z@;7tWcXeD-4rL`Y84B;SJOE`0$Y8QhzjIUqO2Eo3ImHhbQ6Et4>9r~(Z73LgSpVft z+n%Y^Q3NlU3BDDLWKRu>jSMzijDLA>+~zS@2}lJZwDObnTsS00gbEs?EMAhsR`RbI z5E*VJ0q5&Q^8f?8G138EYTYeOpwReru2wOC*BM<=l9 zlEwCfhlfXUQoS<)6&55iTuIIClRTtGXL*)NUq@j~MXQhfV{S#-M>Hg3v+dug7Ygvp zdlnNcYT_`lJw2aq|LM0nHkI>2|8-b#qX?cz_t#q;NXki&K%_UC?7|LU>odpezmBp1 z%me>Zp?Eb;$`Rqgu6Os^&o|0aAF0F-JInMqhlPdx9WlyaQ207d*H{6}JyKaQG~KcN>YtlrD*%B# zS~pEF#y~ozstPbhAnF<5Yj1I!Zv)mokUm`Xyw$RgT%`YRh0$lH~lM z)1@zArgBNk3MK?dHE2;fA2ZVB^^Q!Mr}ysK9bS7for8WV95&lLoCi6z(W;5t&s2eu z_H!I0=bJqkN=&im80^;YHHiCr2!58)N3egM*V|2|iP!^$7`K^VYKoA@T%86ll3h0< zuKu7f_rI`FIetdul#0C!<`x8ZOt_+|O4DjJIow8{)(?n}0s^poGGZW%!tl9KjU|#1 zu66tk;spG>nn1*oJaAve`)I$1VRwBU$x43a3-YtQvXUPc0Xz2DjgIm+XK#pyZdZQO zK}QDW-Q30o& z4AwF!Ua? z)cd8H9*CciZ<~HTCLhuZD?Z>M(pV}SV&ov>md@E|2aa|4+vNHQttLO^BVZw&cg_Hf zMOn&?6O_`>xZ3RNTVvivi=H7pM2WpLIYF4x!Xw`FJ}1;tZ@rp0H9m0csEnmP0^J{q zHGyF$dql9z)7Op8`ug$=z>+`+&oNB5oKXdYeqjIgo7zx^Ss8)(2Xso{xUz}Gy`YX) z%WrBr12j{=mH5tzK^j)4zx|t#`g*bCgm7-A(zH?}kKNNy79>0$N@7e;s}cKm`$-1{ zIf6i$84@_8Wz9h=hL%^y*ZFWV9%#-O;(EQrfK7=2x%dExSP};qE0Feuy?_V7E0by( zPt^SUey@AfHqkBFdGdxReYJ6LU{9Y8Fbj~T3fJ|_N0YKqy#vulmtZQ?_um>bMZ>ms zcL6cA)Q{!{=cFJB1Z7aYE7?Of)k@OGmidORj~AJO_rK)*;&J=6`F>Jd=I%i(4Dyc{ zSk6x^A8F&_XAsXE*i7k<&i0R6Q!8bIZZ)=EzVvwkJVFJkKo_j;O78OAKh2eP zuZ^A`8uTmZJ>2TgaxxK1>~nK>$B_qg5lD5Xp#cLSJuU4o(9VKW21eiP>-FqX;fh33 zfG``L?wB({Tx1VPA&~QK!5rV}M5o(#A%E}lptDcTeG=)XI?osz_oL@Z|+lgg`wDe9>UA zOnp>XTMHJ%E{%#H6s(8!a8|3?SKNUby~1yOJ(4n50*0i6Yo?mMN|sgH+ck5R8z%(v z(1bW=eKN^MXDE428irEy?P2WY@NPTy8uSKc{KW^inB#lF^CHm;9tX{=1?44?!d|@i zv8=?zq#Qb23d{I6R9-cQg>Oli5tzD?t%bJ!2Z`CfppSgB^)o2qMzvNXdt3q-#>_&Fpd>;1ckxN3I$lmT z;;D}a7VtHpCKwl@&Jl^#(#es}Qre|+pw^Y!tpcL62sCy;b#;F3jm{|`PoH!%(}#e5 zserZ>y}S~V_KfX{V*z&rOF^f(@)yO-Q3b)(_mAZ-iRKYGv~{^prZwwi3CTGJ%n*$U zX7}?_3=n4A19vRL)tV}CCy>$>5!g}2p!7^l4Pnon9sc(~5|#E+U*$V$fYQ^GlVZZ% z&zPw+&lh~)fra5L!bP^skYZI&vh;t}W6&XiCJuD^nQ1t=Q_}>~IxnvH7A@1fGiu=n zKcY_NUIsJJSk(ON(0e%iY6gH9AS1(rhpN^=DtkCP%a=e$$Hc^V-2J`105ptN zXzBhHa3y3NV7FbPtSbl>OKcNC9b@yEE-i_T;j64@kig z#L1Dq#qe@;UGTUu5PWQiPgS`aj6b1ZoA(Fg>Rt5hXsd&?z`BMHHsHQw6%+>niT?qs zeTiF`z=y%Ve+PGji8>ny&qP8GIel`HwDs%rX)VDEtuSt?0qMK$XEGN1dh6%U)OYs< zj^&4_OA`L;C@f^394<}3jnNH?fD{+9Q^bVO(r%DjL;`zve6=NeBF%joFTa8RTwe|@ z67>(bYe?`t$v8&$SthB71Ndla%AkN=URyYe2Jxd^QG{$ansmuR$1KBn2twFDWu)V; zC&Muj(mAS@Ur|5l+-l65-gv0f`$}}iZx6!##)+G(n)*m_Tv$p<3IuQgzTse~W11fy zvB>ZTO6emz#0-xY$V`ERDO($ED?kzy$>AVMo9a_uL_HA=qhN@`OaqrL;7_zTDM-R6 zcL8j%5t(#FXgy3_QHMoqwl)%~DHG8+W&SCTZQ=XOM~JttoaOO@RK$LYXR=WfU0%k; z5@lU*E+#(nI<9}2;S}ooC6F;B`A1A>MNZ*<-#H0~U!9;}$%j!y%d`F>zE+1QgZ8LJ z$6}RFrxdqL#Aote8|&Ew6id+S?P0Sqw#BRBaQTGH zfhgY?`J`k>`~5osGggnRuCme4Fv9%n6S_Oh1WYCvg`pz1e zqVy8)7A(Q}e*b|MubLh^R{ilQ;pNN|cg8p^T%)6?np2N!0Q79b!xF;BGVz z<%gIr&`=!al=|6OGf;NT3!^Z?_cIm$oGU&j9l~LJ+bvS4fy=mV@^jPiA$gYTYa7Yc z-^l@vq;X8E#_m3k*%R;OFY6XZ1f+F~m@H*n47E4nB&boETb@kobFye1q}V1FAIS}{bO!0yb{k78W=Q7Wbe%gtx7&PkIr5FEJnRqi_Eqp=V_ zyB>z(m@E-Vl#RSmnfx;|0biRqY=iNz|2()~T6fm4_n zG^PmAfidFZz;aASj9}Y~E`sn<1}zPk1Pg zBcfwDc86Lr!&j+S{iBX@2Ti$@I01Y1D|$S#oG_27*?FpMbOIvvmOO+=jr=~+I>=R` zZ?~exhlbJmN6n8u2jAzpHK$eG?kG%M-vf4Ka`(0ZvHAij0sPcpC~Hu9v1^|5FMTo` zn^yzzKRzf$B8OPX&DMHFm?cVx@gc(3GbT!ZngS_fbJ-wfX3ol5MtEoj%>-xyBTjsJ zp?fYOfS(DgCl=^A=|~j%V=_SgXx0A0HSGbLc(6vb&p{7~f+zj8TU$ zvXKXtA1Nh5I#?g68J4J6qHeG~obK1wW(B~V%X z*7mGws62#os9!m-@vnd!gVyNzCVno^c$)Nv{cH+Mw135JZ;|Oy>~=LNO!8wFd+-KQ zGU4zGgN4XXxA)pVxb-=W`mcSEF0jVjg5`))X*}=kGt|Pz!bjaR^w`H~UhL=aB5Md| zxwIZI5-_%ANhpw$Q>N;KRT zMJ4~Yn~pi1lXwx|mlDm=-}gxI&{>^PE#@tmQl6aM8+#rlW~V~!g=<5}iYwpm>oC@W z3HR3e`eT3-Ouye1oS^MpZ1Rfhg`{NzXgRmW?!{QI1lf`sBxI#O5%qM z5Q87ZeAgpLxj+cf)%B-65 zqJ)=h0C9_8m*=uLhBxqgTs^h&mHCOr)hoUl;vQSTuN7gJlqGtLPZ@T;M7WiA7+i!Et zI4M5yaKrhD$N7mWor5iO)iz-YnM1EM|H4ry4;hRlRs7bDtjo3t23Vwl)S0mU3whPPob)#<8WNxnl8!$!DxIWkxF{mgcgs` zmGl>NAd*^fQcoj6M-3U}o|Q(-7qSNi<5ph6HbcvPg2m?7Pd?<@(#q2;^z`i4i{X=$$j`1jM}?M|`zcLQyjFSFMK!=56J( z*G*mS8OXn$+14p30T6lRAo)K4H@-No3fOLbtzqH1R9E;U|AXyqod53shaaj0BZ5Ck zW#wG5c=78lrJS4>Q4f6?zGfDfrtpXFDpHt`;xQFFqr^VjrZvCj+|rG(xHi=Yw7m^zTMhFVDXELw_yG!|Rysi>f^OiaspBk~6w_RygA0}D z3C8zY*2E<0nl6E1SeUk=Br>FP!Hn_MlR|Cy7A^U5=H;J1qu_HWR)!BQf+qvC(r&xn z+>r{}1oRRv%hT4`v)6zBSu%6wYlQ{brez2qroVORKcQ^>z`jnA0b?uF5=d>%r=&a$ zu`A%NOzg^sa}F-(F$g7jhWAF<buc+rK2L^6fCC3NRrjtkGs6jc~|xm#OX z%Vrl5)~%XKAXaX|*oZqvZW7->(lCh9r6e@Sv-pitFKueRfT_uIq)~T;EGF)Vd`M9( zh$juP5>*TGb0W9}QGb^0`AP6`HdVw@=3bwVE{E>5=Kc}2R0A#DMJKY65F`?tnV9(T z7;uLGKoxiDtX}{htjM}f#X4`oe{LU~qFsu}ZFSU#59by$)(=S%mrU{5wMzs-mq w>}GtUY0&#p-1bqnkmQ Date: Fri, 10 Jul 2020 17:10:57 -0400 Subject: [PATCH 16/43] Refactor: remove states.json --- .gitignore | 3 ++- states.json | 61 ----------------------------------------------------- 2 files changed, 2 insertions(+), 62 deletions(-) delete mode 100644 states.json diff --git a/.gitignore b/.gitignore index 1c8c437..1d452bf 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,5 @@ dmypy.json output.csv venv8/ quickstart.py -config.yaml \ No newline at end of file +config.yaml +states.json \ No newline at end of file diff --git a/states.json b/states.json deleted file mode 100644 index f2a89b5..0000000 --- a/states.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "AL": "Alabama", - "AK": "Alaska", - "AS": "American Samoa", - "AZ": "Arizona", - "AR": "Arkansas", - "CA": "California", - "CO": "Colorado", - "CT": "Connecticut", - "DE": "Delaware", - "DC": "District Of Columbia", - "FM": "Federated States Of Micronesia", - "FL": "Florida", - "GA": "Georgia", - "GU": "Guam", - "HI": "Hawaii", - "ID": "Idaho", - "IL": "Illinois", - "IN": "Indiana", - "IA": "Iowa", - "KS": "Kansas", - "KY": "Kentucky", - "LA": "Louisiana", - "ME": "Maine", - "MH": "Marshall Islands", - "MD": "Maryland", - "MA": "Massachusetts", - "MI": "Michigan", - "MN": "Minnesota", - "MS": "Mississippi", - "MO": "Missouri", - "MT": "Montana", - "NE": "Nebraska", - "NV": "Nevada", - "NH": "New Hampshire", - "NJ": "New Jersey", - "NM": "New Mexico", - "NY": "New York", - "NC": "North Carolina", - "ND": "North Dakota", - "MP": "Northern Mariana Islands", - "OH": "Ohio", - "OK": "Oklahoma", - "OR": "Oregon", - "PW": "Palau", - "PA": "Pennsylvania", - "PR": "Puerto Rico", - "RI": "Rhode Island", - "SC": "South Carolina", - "SD": "South Dakota", - "TN": "Tennessee", - "TX": "Texas", - "UT": "Utah", - "VT": "Vermont", - "VI": "Virgin Islands", - "VA": "Virginia", - "WA": "Washington", - "WV": "West Virginia", - "WI": "Wisconsin", - "WY": "Wyoming" -} \ No newline at end of file From 70a8df2ebe7977f2655fd92ede40484d1815f769 Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 10 Jul 2020 17:41:17 -0400 Subject: [PATCH 17/43] Bug: Add config.yaml file --- .gitignore | 1 - config.yaml | 11 +++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 config.yaml diff --git a/.gitignore b/.gitignore index 1d452bf..f4a6d8d 100644 --- a/.gitignore +++ b/.gitignore @@ -142,5 +142,4 @@ dmypy.json output.csv venv8/ quickstart.py -config.yaml states.json \ No newline at end of file diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..c549cd7 --- /dev/null +++ b/config.yaml @@ -0,0 +1,11 @@ +username: # Insert your username here +password: # Insert your password here + +positions: +- # Position you want to search for +- # Another position you want to search for +- # A third position you want to search for + +locations: +- # Location you want to search in +- # A second location you want to search in \ No newline at end of file From 96ba0fa5b70467c67927b7a519c5eab10350afaa Mon Sep 17 00:00:00 2001 From: krapes Date: Sat, 11 Jul 2020 08:51:49 -0400 Subject: [PATCH 18/43] Feature: max combo lenght --- easyapplybot.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/easyapplybot.py b/easyapplybot.py index d85e135..1052358 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -110,6 +110,8 @@ def start_apply(self, positions, locations): print(f"Applying to {position}: {location}") location = "&location=" + location self.applications_loop(position, location) + if len(combos) > 20: + break self.finish_apply() def applications_loop(self, position, location): From fe5766a42d20e7dd8682f8ba52e59f05a5878343 Mon Sep 17 00:00:00 2001 From: krapes Date: Sun, 12 Jul 2020 16:21:43 -0400 Subject: [PATCH 19/43] Features: JobIDs only looking back one day --- .gitignore | 1 + easyapplybot.py | 75 ++++++++++++++++++++++++++++++++----------------- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index f4a6d8d..bf0f43a 100644 --- a/.gitignore +++ b/.gitignore @@ -140,6 +140,7 @@ dmypy.json # User files output.csv +output*.csv venv8/ quickstart.py states.json \ No newline at end of file diff --git a/easyapplybot.py b/easyapplybot.py index 1052358..28b82c2 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -1,4 +1,4 @@ -import time, random, os, csv, datetime, platform +import time, random, os, csv, platform from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.common.exceptions import TimeoutException @@ -15,6 +15,7 @@ from webdriver_manager.chrome import ChromeDriverManager import re import yaml +from datetime import datetime, timedelta driver = webdriver.Chrome(ChromeDriverManager().install()) @@ -26,28 +27,35 @@ class EasyApplyBot: blacklist = ["Staffigo"] - def __init__(self, username, password, language, resumeloctn=None, filename='output.csv'): + def __init__(self, username, password, cover_letter_loctn=None, filename='output.csv'): print("\nWelcome to Easy Apply Bot\n") dirpath = os.getcwd() print("current directory is : " + dirpath) - self.resumeloctn = resumeloctn - self.language = language - self.appliedJobIDs = self.get_appliedIDs(filename) + self.cover_letter_loctn = cover_letter_loctn + self.appliedJobIDs = self.get_appliedIDs(filename)#if self.get_appliedIDs(filename) != None else [] self.filename = filename self.options = self.browser_options() self.browser = driver self.wait = WebDriverWait(self.browser, 30) - self.start_linkedin(username,password) + self.start_linkedin(username, password) def get_appliedIDs(self, filename): + print(filename) try: df = pd.read_csv(filename, header=None, - names=['timestamp', 'jobID', 'job', 'company', 'attempted', 'result']) - return list(df.jobID) + names=['timestamp', 'jobID', 'job', 'company', 'attempted', 'result'], + lineterminator='\n', + encoding = 'utf-8') + + df['timestamp'] = pd.to_datetime(df['timestamp'], format="%Y-%m-%d %H:%M:%S.%f") + df = df[df['timestamp'] > (datetime.now() - timedelta(days=2))] + jobIDs = list(df.jobID) + print(f"{len(jobIDs)} jobIDs found") + return jobIDs except Exception as e: print(str(e) + " jobIDs could not be loaded from CSV {}".format(filename)) return None @@ -78,8 +86,6 @@ def start_linkedin(self,username,password): print("TimeoutException! Username/password field or login button not found") def wait_for_login(self): - - time.sleep(1) while True: @@ -94,7 +100,7 @@ def fill_data(self): self.browser.set_window_size(0, 0) self.browser.set_window_position(2000, 2000) - print(self.resumeloctn) + print(self.cover_letter_loctn) def start_apply(self, positions, locations): start = time.time() @@ -158,16 +164,20 @@ def applications_loop(self, position, location): IDs = set(IDs) # remove already applied jobs + before = len(IDs) jobIDs = [x for x in IDs if x not in self.appliedJobIDs] + after = len(jobIDs) + print(f"""There were {before} jobIDs found + but {before - after} were removed because they were found on the + appliedJobsID list""") - if len(jobIDs) == 0: + if len(jobIDs) == 0 and len(IDs) > 24: jobs_per_page = jobs_per_page + 25 count_job = 0 self.avoid_lock() self.browser, jobs_per_page = self.next_jobs_page(position, location, jobs_per_page) - # loop over IDs to apply for i, jobID in enumerate(jobIDs): count_job += 1 @@ -196,7 +206,7 @@ def applications_loop(self, position, location): print(f'\n\n********count_application: {count_application}************\n\n') print(f"Time for a nap - see you in:{int(sleepTime/60)} min") print('\n\n****************************************\n\n') - time.sleep (sleepTime) + time.sleep(sleepTime) # go to new page if all jobs are done if count_job == len(jobIDs): @@ -212,6 +222,7 @@ def applications_loop(self, position, location): if len(jobIDs) == 0 or i == (len(jobIDs) - 1): break + def write_to_file(self, button, jobID, browserTitle, result): def re_extract(text, pattern): target = re.search(pattern, text) @@ -219,7 +230,7 @@ def re_extract(text, pattern): target = target.group(1) return target - timestamp = datetime.datetime.now() + timestamp = datetime.now() attempted = False if button == False else True job = re_extract(browserTitle.split(' | ')[0], r"\(?\d?\)?\s?(\w.*)") company = re_extract(browserTitle.split(' | ')[1], r"(\w.*)" ) @@ -294,22 +305,36 @@ def is_present(button_locator): try: time.sleep(3) #print(f"Navigating... ") - next_locater = (By.CSS_SELECTOR, "button[aria-label='Continue to next step']") - review_locater = (By.CSS_SELECTOR, "button[aria-label='Review your application']") - submit_locater = (By.CSS_SELECTOR, "button[aria-label='Submit application']") - submit_application_locator = (By.CSS_SELECTOR, "button[aria-label='Submit application']") - error_locator = (By.CSS_SELECTOR, "p[data-test-form-element-error-message='true']") - + next_locater = (By.CSS_SELECTOR, + "button[aria-label='Continue to next step']") + review_locater = (By.CSS_SELECTOR, + "button[aria-label='Review your application']") + submit_locater = (By.CSS_SELECTOR, + "button[aria-label='Submit application']") + submit_application_locator = (By.CSS_SELECTOR, + "button[aria-label='Submit application']") + error_locator = (By.CSS_SELECTOR, + "p[data-test-form-element-error-message='true']") + cover_letter = (By.CSS_SELECTOR, "input[name='file']") + submitted = False while True: button = None - #self.browser.find_element_by_xpath('//*[@id="file-browse-input"]').send_keys(self.resumeloctn) + + for i, button_locator in enumerate([next_locater, review_locater, submit_locater, submit_application_locator]): - #print(i) + + if is_present(cover_letter): + input_button = self.browser.find_elements(cover_letter[0], + cover_letter[1]) + + input_button[0].send_keys(self.cover_letter_loctn) + time.sleep(6) + if is_present(button_locator): #print("button found") button = self.wait.until(EC.element_to_be_clickable(button_locator)) - + if is_present(error_locator): for element in self.browser.find_elements(error_locator[0], error_locator[1]): @@ -343,7 +368,7 @@ def is_present(button_locator): except Exception as e: print(e) print("cannot apply to this job") - #raise(e) + raise(e) return submitted From c67e30882a97bdb69fa5529da7f2075f13522380 Mon Sep 17 00:00:00 2001 From: krapes Date: Sun, 12 Jul 2020 16:45:40 -0400 Subject: [PATCH 20/43] Feature: Reorganize send_resume while loop --- easyapplybot.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 28b82c2..9646141 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -303,7 +303,7 @@ def is_present(button_locator): button_locator[1])) > 0 try: - time.sleep(3) + time.sleep(random.uniform(1.5, 2.5)) #print(f"Navigating... ") next_locater = (By.CSS_SELECTOR, "button[aria-label='Continue to next step']") @@ -319,20 +319,21 @@ def is_present(button_locator): submitted = False while True: - button = None - - for i, button_locator in enumerate([next_locater, review_locater, submit_locater, submit_application_locator]): + # Upload Cover Letter if possible + if is_present(cover_letter): + input_button = self.browser.find_elements(cover_letter[0], + cover_letter[1]) - if is_present(cover_letter): - input_button = self.browser.find_elements(cover_letter[0], - cover_letter[1]) - - input_button[0].send_keys(self.cover_letter_loctn) - time.sleep(6) + input_button[0].send_keys(self.cover_letter_loctn) + time.sleep(random.uniform(4.5, 6.5)) + # Click Next or submitt button if possible + button = None + buttons = [next_locater, review_locater, + submit_locater, submit_application_locator] + for i, button_locator in enumerate(buttons): if is_present(button_locator): - #print("button found") button = self.wait.until(EC.element_to_be_clickable(button_locator)) if is_present(error_locator): @@ -340,8 +341,6 @@ def is_present(button_locator): error_locator[1]): text = element.text if "Please enter a valid answer" in text: - #print("Error Found") - #print(element.get_attribute('class')) button = None break if button: From 21110d7d9e59220016e1776aa29b6708a16ac050 Mon Sep 17 00:00:00 2001 From: krapes Date: Sun, 12 Jul 2020 16:50:43 -0400 Subject: [PATCH 21/43] Refactor: Remove unused function got_easy_button --- easyapplybot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 9646141..c01f5f5 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -256,7 +256,7 @@ def get_job_page(self, jobID): self.browser.get(job) self.job_page = self.load_page(sleep=0.5) return self.job_page - + ''' def got_easy_apply(self, page): #button = page.find("button", class_="jobs-apply-button artdeco-button jobs-apply-button--top-card artdeco-button--3 ember-view") @@ -268,7 +268,7 @@ def got_easy_apply(self, page): return EasyApplyButton else : return False - + ''' def get_easy_apply_button(self): try : From 6937826bc3493f85cb3107c82115986a5b3693eb Mon Sep 17 00:00:00 2001 From: krapes Date: Sun, 12 Jul 2020 16:57:04 -0400 Subject: [PATCH 22/43] Refactor: remove unused function --- easyapplybot.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index c01f5f5..69d28d5 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -240,14 +240,6 @@ def re_extract(text, pattern): writer = csv.writer(f) writer.writerow(toWrite) - def get_job_links(self, page): - links = [] - for link in page.find_all('a'): - url = link.get('href') - if url: - if '/jobs/view' in url: - links.append(url) - return set(links) def get_job_page(self, jobID): #root = 'www.linkedin.com' @@ -256,19 +248,7 @@ def get_job_page(self, jobID): self.browser.get(job) self.job_page = self.load_page(sleep=0.5) return self.job_page - ''' - def got_easy_apply(self, page): - #button = page.find("button", class_="jobs-apply-button artdeco-button jobs-apply-button--top-card artdeco-button--3 ember-view") - button = self.browser.find_elements_by_xpath( - '//button[contains(@class, "jobs-apply")]/span[1]' - ) - EasyApplyButton = button [0] - if EasyApplyButton.text in "Easy Apply" : - return EasyApplyButton - else : - return False - ''' def get_easy_apply_button(self): try : From fb1530819090fbb302844dd8496b9f821b49cbe5 Mon Sep 17 00:00:00 2001 From: krapes Date: Sun, 12 Jul 2020 17:07:47 -0400 Subject: [PATCH 23/43] Refactor: delete useless code --- easyapplybot.py | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 69d28d5..9452e96 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -85,16 +85,6 @@ def start_linkedin(self,username,password): except TimeoutException: print("TimeoutException! Username/password field or login button not found") - def wait_for_login(self): - time.sleep(1) - - while True: - if self.browser.title != title: - print("\nStarting LinkedIn bot\n") - break - else: - time.sleep(1) - print("\nPlease Login to your LinkedIn account\n") def fill_data(self): self.browser.set_window_size(0, 0) @@ -167,9 +157,7 @@ def applications_loop(self, position, location): before = len(IDs) jobIDs = [x for x in IDs if x not in self.appliedJobIDs] after = len(jobIDs) - print(f"""There were {before} jobIDs found - but {before - after} were removed because they were found on the - appliedJobsID list""") + if len(jobIDs) == 0 and len(IDs) > 24: jobs_per_page = jobs_per_page + 25 @@ -262,20 +250,6 @@ def get_easy_apply_button(self): return EasyApplyButton - def easy_apply_xpath(self): - button = self.get_easy_apply_button() - button_inner_html = str(button) - list_of_words = button_inner_html.split() - next_word = [word for word in list_of_words if "ember" in word and "id" in word] - ember = next_word[0][:-1] - xpath = '//*[@'+ember+']/button' - return xpath - - def click_button(self, xpath): - triggerDropDown = self.browser.find_element_by_xpath(xpath) - time.sleep(0.5) - triggerDropDown.click() - time.sleep(1) def send_resume(self): def is_present(button_locator): From 8321477aa0ce6d5162e05df9751fb0e0ce755192 Mon Sep 17 00:00:00 2001 From: krapes Date: Sun, 12 Jul 2020 17:08:53 -0400 Subject: [PATCH 24/43] Feature: support for if output.csv doesnt exist --- easyapplybot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/easyapplybot.py b/easyapplybot.py index 9452e96..cc4b477 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -34,7 +34,7 @@ def __init__(self, username, password, cover_letter_loctn=None, filename='output print("current directory is : " + dirpath) self.cover_letter_loctn = cover_letter_loctn - self.appliedJobIDs = self.get_appliedIDs(filename)#if self.get_appliedIDs(filename) != None else [] + self.appliedJobIDs = self.get_appliedIDs(filename) if self.get_appliedIDs(filename) != None else [] self.filename = filename self.options = self.browser_options() self.browser = driver From cbe5a8946c4ccb9dc6703ec88203f0b78a32cc21 Mon Sep 17 00:00:00 2001 From: krapes Date: Sun, 12 Jul 2020 17:43:49 -0400 Subject: [PATCH 25/43] Feature: config support for cover letter and output file --- config.yaml | 16 +++++++++++----- easyapplybot.py | 12 ++++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/config.yaml b/config.yaml index c549cd7..483220b 100644 --- a/config.yaml +++ b/config.yaml @@ -1,11 +1,17 @@ -username: # Insert your username here -password: # Insert your password here +username: +password: positions: -- # Position you want to search for +- # positions you want to search for - # Another position you want to search for - # A third position you want to search for locations: -- # Location you want to search in -- # A second location you want to search in \ No newline at end of file +- # Location you want to search for +- # A second location you want to search in + +cover_letter_loctn: +- # '/home/PATH_TO_FILE' + +output_filename: +- # PATH TO OUTPUT FILE (default output.csv) \ No newline at end of file diff --git a/easyapplybot.py b/easyapplybot.py index cc4b477..d6c6e32 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -374,9 +374,17 @@ def finish_apply(self): assert parameters['username'] is not None assert parameters['password'] is not None + + print(parameters) + cover_letter_loctn = parameters.get('cover_letter_loctn', [None])[0] + output_filename = parameters.get('output_filename', ['output.csv'])[0] + bot = EasyApplyBot(parameters['username'], parameters['password'], - parameters['locations'] + cover_letter_loctn=cover_letter_loctn, + filename=output_filename ) - bot.start_apply(parameters['positions'], parameters['locations']) \ No newline at end of file + locations = [l for l in parameters['locations'] if l != None] + positions = [p for p in parameters['positions'] if p != None] + bot.start_apply(positions, locations) \ No newline at end of file From 6812762ab4d5d78ba2f7d8caaad335336ebdff2f Mon Sep 17 00:00:00 2001 From: krapes Date: Sun, 12 Jul 2020 17:52:41 -0400 Subject: [PATCH 26/43] Feature: update README --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b4af5b0..43f862a 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,19 @@ username: # Insert your username here password: # Insert your password here positions: -- # Position you want to search for +- # positions you want to search for - # Another position you want to search for - # A third position you want to search for locations: -- # Location you want to search in +- # Location you want to search for - # A second location you want to search in + +cover_letter_loctn: +- # '/home/PATH_TO_FILE' + +output_filename: +- # PATH TO OUTPUT FILE (default output.csv) ``` __NOTE: AFTER EDITING SAVE FILE, DO NOT COMMIT FILE__ From e628c37e81339e44186d605165d351adc0936dc6 Mon Sep 17 00:00:00 2001 From: krapes Date: Sun, 12 Jul 2020 22:03:21 -0400 Subject: [PATCH 27/43] Feature: Breakout blacklist feature to the config file --- README.md | 3 +++ config.yaml | 5 ++++- easyapplybot.py | 13 ++++++++++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 43f862a..e5e0206 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ cover_letter_loctn: output_filename: - # PATH TO OUTPUT FILE (default output.csv) + +blacklist: +- # Company names you want to ignore ``` __NOTE: AFTER EDITING SAVE FILE, DO NOT COMMIT FILE__ diff --git a/config.yaml b/config.yaml index 483220b..655292f 100644 --- a/config.yaml +++ b/config.yaml @@ -14,4 +14,7 @@ cover_letter_loctn: - # '/home/PATH_TO_FILE' output_filename: -- # PATH TO OUTPUT FILE (default output.csv) \ No newline at end of file +- # PATH TO OUTPUT FILE (default output.csv) + +blacklist: +- # Company names you want to ignore \ No newline at end of file diff --git a/easyapplybot.py b/easyapplybot.py index d6c6e32..224bf2b 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -24,10 +24,15 @@ class EasyApplyBot: MAX_SEARCH_TIME = 10*60 - blacklist = ["Staffigo"] - def __init__(self, username, password, cover_letter_loctn=None, filename='output.csv'): + + def __init__(self, + username, + password, + cover_letter_loctn=None, + filename='output.csv', + blacklist=[]): print("\nWelcome to Easy Apply Bot\n") dirpath = os.getcwd() @@ -378,11 +383,13 @@ def finish_apply(self): print(parameters) cover_letter_loctn = parameters.get('cover_letter_loctn', [None])[0] output_filename = parameters.get('output_filename', ['output.csv'])[0] + blacklist = parameters.get('blacklist', []) bot = EasyApplyBot(parameters['username'], parameters['password'], cover_letter_loctn=cover_letter_loctn, - filename=output_filename + filename=output_filename, + blacklist=blacklist ) locations = [l for l in parameters['locations'] if l != None] From 9778332af9a307549c478c18d8f0d9e050e20f67 Mon Sep 17 00:00:00 2001 From: krapes Date: Sun, 12 Jul 2020 22:23:44 -0400 Subject: [PATCH 28/43] Debug: self.blacklist = blacklist was not in __init__ --- easyapplybot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/easyapplybot.py b/easyapplybot.py index 224bf2b..d1a35ac 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -44,6 +44,7 @@ def __init__(self, self.options = self.browser_options() self.browser = driver self.wait = WebDriverWait(self.browser, 30) + self.blacklist = blacklist self.start_linkedin(username, password) From 51f865ba8eab4d67ad7eda89441008e2e622131e Mon Sep 17 00:00:00 2001 From: spectrem12 Date: Wed, 15 Jul 2020 00:27:35 -0500 Subject: [PATCH 29/43] Able to answer additional questions at the end of the application. --- easyapplybot.py | 399 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 265 insertions(+), 134 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 2d48a5b..4adcc59 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -7,6 +7,8 @@ from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.relative_locator import RelativeBy +from selenium.webdriver.support.relative_locator import with_tag_name from bs4 import BeautifulSoup import pandas as pd import pyautogui @@ -16,21 +18,34 @@ import loginGUI from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager +import json +from datetime import datetime +import logging +import re +from windowfinder import WindowFinder +import win32com.client as comctl +wsh =comctl.Dispatch("WScript.Shell") + + + + + +log = logging.getLogger(__name__) driver = webdriver.Chrome(ChromeDriverManager().install()) + # pyinstaller --onefile --windowed --icon=app.ico easyapplybot.py class EasyApplyBot: - MAX_APPLICATIONS = 5 - def __init__(self,username,password, language, positions, locations, resumeloctn, appliedJobIDs=[], filename='output.csv'): + def __init__(self, username, password, language, positions, locations, resumeloctn, appliedJobIDs=[], + filename='output.csv'): - print("\nWelcome to Easy Apply Bot\n") + log.info("Welcome to Easy Apply Bot\n") dirpath = os.getcwd() - print("current directory is : " + dirpath) - + log.info("current directory is : " + dirpath) self.positions = positions self.locations = locations @@ -41,23 +56,22 @@ def __init__(self,username,password, language, positions, locations, resumeloctn self.options = self.browser_options() self.browser = driver self.wait = WebDriverWait(self.browser, 30) - self.start_linkedin(username,password) - + self.start_linkedin(username, password) def browser_options(self): options = Options() options.add_argument("--start-maximized") options.add_argument("--ignore-certificate-errors") - #options.add_argument("user-agent=Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393") - #options.add_argument('--headless') + # options.add_argument("user-agent=Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393") + # options.add_argument('--headless') options.add_argument('--no-sandbox') - #options.add_argument('--disable-gpu') - #options.add_argument('disable-infobars') + # options.add_argument('--disable-gpu') + # options.add_argument('disable-infobars') options.add_argument("--disable-extensions") return options - def start_linkedin(self,username,password): - print("\nLogging in.....\n \nPlease wait :) \n ") + def start_linkedin(self, username, password): + log.info("Logging in.....Please wait :) ") self.browser.get("https://www.linkedin.com/login?trk=guest_homepage-basic_nav-header-signin") try: user_field = self.browser.find_element_by_id("username") @@ -70,41 +84,42 @@ def start_linkedin(self,username,password): time.sleep(1) login_button.click() except TimeoutException: - print("TimeoutException! Username/password field or login button not found") + log.info("TimeoutException! Username/password field or login button not found") def wait_for_login(self): if language == "en": - title = "Sign In to LinkedIn" + title = "Sign In to LinkedIn" elif language == "es": - title = "Inicia sesiĆ³n" + title = "Inicia sesiĆ³n" elif language == "pt": - title = "Entrar no LinkedIn" + title = "Entrar no LinkedIn" time.sleep(1) while True: if self.browser.title != title: - print("\nStarting LinkedIn bot\n") + log.info("Starting LinkedIn bot\n") break else: time.sleep(1) - print("\nPlease Login to your LinkedIn account\n") + log.info("Please Login to your LinkedIn account\n") def fill_data(self): self.browser.set_window_size(0, 0) self.browser.set_window_position(2000, 2000) os.system("reset") - print(self.resumeloctn) + log.info(self.resumeloctn) def start_apply(self): - #self.wait_for_login() + # self.wait_for_login() self.fill_data() - for position in self.positions: - for location in self.locations: - print(f"Applying to {position}: {location}") - location = "&location=" + location - self.applications_loop(position, location) + # TODO commented out positions and locations for loops since they caused issues with search. Need to fix later + # for position in self.positions: + # for location in self.locations: + log.info(f"Applying to {position}: {location}") + # location = "&location=" + location + self.applications_loop(position, location) self.finish_apply() def applications_loop(self, position, location): @@ -115,31 +130,33 @@ def applications_loop(self, position, location): os.system("reset") - print("\nLooking for jobs.. Please wait..\n") + log.info("Looking for jobs.. Please wait..") self.browser.set_window_position(0, 0) self.browser.maximize_window() self.browser, _ = self.next_jobs_page(position, location, jobs_per_page) - print("\nLooking for jobs.. Please wait..\n") - #below was causing issues, and not sure what they are for. - #self.browser.find_element_by_class_name("jobs-search-dropdown__trigger-icon").click() - #self.browser.find_element_by_class_name("jobs-search-dropdown__option").click() - #self.job_page = self.load_page(sleep=0.5) + log.info("Looking for jobs.. Please wait..") + # below was causing issues, and not sure what they are for. + # self.browser.find_element_by_class_name("jobs-search-dropdown__trigger-icon").click() + # self.browser.find_element_by_class_name("jobs-search-dropdown__option").click() + # self.job_page = self.load_page(sleep=0.5) while count_application < self.MAX_APPLICATIONS: # sleep to make sure everything loads, add random to make us look human. - time.sleep(random.uniform(3.5, 6.9)) + randoTime = random.uniform(3.5, 6.9) + log.info("Sleeping for %s", randoTime) + time.sleep(randoTime) self.load_page(sleep=1) # get job links links = self.browser.find_elements_by_xpath( - '//div[@data-job-id]' - ) + '//div[@data-job-id]' + ) # get job ID of each job link IDs = [] - for link in links : + for link in links: temp = link.get_attribute("data-job-id") jobID = temp.split(":")[-1] IDs.append(int(jobID)) @@ -151,10 +168,10 @@ def applications_loop(self, position, location): if len(jobIDs) == 0: jobs_per_page = jobs_per_page + 25 count_job = 0 - self.avoid_lock() + #self.avoid_lock() self.browser, jobs_per_page = self.next_jobs_page(position, - location, - jobs_per_page) + location, + jobs_per_page) # loop over IDs to apply for jobID in jobIDs: @@ -162,11 +179,11 @@ def applications_loop(self, position, location): self.get_job_page(jobID) # get easy apply button - button = self.get_easy_apply_button () + button = self.get_easy_apply_button() if button is not False: string_easy = "* has Easy Apply Button" button.click() - time.sleep (3) + time.sleep(3) result = self.send_resume() count_application += 1 else: @@ -174,36 +191,37 @@ def applications_loop(self, position, location): result = False position_number = str(count_job + jobs_per_page) - print(f"\nPosition {position_number}:\n {self.browser.title} \n {string_easy} \n") + log.info(f"Position {position_number}:\n {self.browser.title} \n {string_easy} \n") # append applied job ID to csv file - timestamp = datetime.datetime.now() - toWrite = [timestamp, jobID, str(self.browser.title).split(' | ')[0], str(self.browser.title).split(' | ')[1], button, result] - with open(self.filename,'a') as f: + timestamp = datetime.now() + toWrite = [timestamp, jobID, str(self.browser.title).split(' | ')[0], + str(self.browser.title).split(' | ')[1], button, result] + log.info("Saving the following row in joblist") + log.info(str(toWrite)) + with open(self.filename, 'a') as f: writer = csv.writer(f) writer.writerow(toWrite) # sleep every 20 applications - if count_application != 0 and count_application % 20 == 0: + if count_application != 0 and count_application % 20 == 0: sleepTime = random.randint(500, 900) - print(f'\n\n********count_application: {count_application}************\n\n') - print(f"Time for a nap - see you in:{int(sleepTime/60)} min") - print('\n\n****************************************\n\n') - time.sleep (sleepTime) + log.info(f'********count_application: {count_application}************\n\n') + log.info(f"Time for a nap - see you in:{int(sleepTime / 60)} min") + log.info('****************************************\n\n') + time.sleep(sleepTime) # go to new page if all jobs are done if count_job == len(jobIDs): jobs_per_page = jobs_per_page + 25 count_job = 0 - print('\n\n****************************************\n\n') - print('Going to next jobs page, YEAAAHHH!!') - print('\n\n****************************************\n\n') - self.avoid_lock() + log.info('****************************************\n\n') + log.info('Going to next jobs page, YEAAAHHH!!') + log.info('****************************************\n\n') + #self.avoid_lock() self.browser, jobs_per_page = self.next_jobs_page(position, - location, - jobs_per_page) - - + location, + jobs_per_page) def get_job_links(self, page): links = [] @@ -215,34 +233,35 @@ def get_job_links(self, page): return set(links) def get_job_page(self, jobID): - #root = 'www.linkedin.com' - #if root not in job: - job = 'https://www.linkedin.com/jobs/view/'+ str(jobID) + # root = 'www.linkedin.com' + # if root not in job: + job = 'https://www.linkedin.com/jobs/view/' + str(jobID) + log.info("Opening Job Page \n %s", job) self.browser.get(job) self.job_page = self.load_page(sleep=0.5) return self.job_page def got_easy_apply(self, page): - #button = page.find("button", class_="jobs-apply-button artdeco-button jobs-apply-button--top-card artdeco-button--3 ember-view") + # button = page.find("button", class_="jobs-apply-button artdeco-button jobs-apply-button--top-card artdeco-button--3 ember-view") button = self.browser.find_elements_by_xpath( - '//button[contains(@class, "jobs-apply")]/span[1]' - ) - EasyApplyButton = button [0] - if EasyApplyButton.text in "Easy Apply" : + '//button[contains(@class, "jobs-apply")]/span[1]' + ) + EasyApplyButton = button[0] + if EasyApplyButton.text in "Easy Apply": return EasyApplyButton - else : + else: return False - #return len(str(button)) > 4 + # return len(str(button)) > 4 def get_easy_apply_button(self): - try : + try: button = self.browser.find_elements_by_xpath( - '//button[contains(@class, "jobs-apply")]/span[1]' - ) - #if button[0].text in "Easy Apply" : - EasyApplyButton = button [0] - except : + '//button[contains(@class, "jobs-apply")]/span[1]' + ) + # if button[0].text in "Easy Apply" : + EasyApplyButton = button[0] + except: EasyApplyButton = False return EasyApplyButton @@ -253,7 +272,7 @@ def easy_apply_xpath(self): list_of_words = button_inner_html.split() next_word = [word for word in list_of_words if "ember" in word and "id" in word] ember = next_word[0][:-1] - xpath = '//*[@'+ember+']/button' + xpath = '//*[@' + ember + ']/button' return xpath def click_button(self, xpath): @@ -262,70 +281,174 @@ def click_button(self, xpath): triggerDropDown.click() time.sleep(1) + def is_jsonable(self, x): + try: + json.dumps(x) + return True + except: + return False + def send_resume(self): def is_present(button_locator): return len(self.browser.find_elements(button_locator[0], - button_locator[1])) > 0 + button_locator[1])) > 0 try: time.sleep(3) - #print(f"Navigating... ") + log.info("Attempting to send resume") + #TODO These locators are not future proof. These labels could easily change. Ideally we would search for contained text; + # was unable to get it to work using XPATH and searching for contained text + upload_locater = (By.CSS_SELECTOR, "label[aria-label='DOC, DOCX, PDF formats only (2 MB).']") next_locater = (By.CSS_SELECTOR, "button[aria-label='Continue to next step']") review_locater = (By.CSS_SELECTOR, "button[aria-label='Review your application']") submit_locater = (By.CSS_SELECTOR, "button[aria-label='Submit application']") submit_application_locator = (By.CSS_SELECTOR, "button[aria-label='Submit application']") error_locator = (By.CSS_SELECTOR, "p[data-test-form-element-error-message='true']") - + + testLabel_locator = (By.XPATH, "//span[@data-test-form-element-label-title='true']") + yes_locator = (By.XPATH, "//input[@value='Yes']") + no_locator = (By.XPATH, "//input[@value='No']") + textInput_locator = (By.XPATH, "//div[@data-test-single-line-text-input-wrap='true']") + + submitted = False - while True: + attemptQuestions = True + while not submitted: button = None - for i, button_locator in enumerate([next_locater, review_locater, submit_locater, submit_application_locator]): - #print(i) + for i, button_locator in enumerate( + [upload_locater, next_locater, review_locater, submit_locater, submit_application_locator]): + + log.info("Searching for button locator: %s", str(button_locator)) if is_present(button_locator): - #print("button found") + log.info("button found with this locator: %s", str(button_locator)) button = self.wait.until(EC.element_to_be_clickable(button_locator)) - + else: + log.info("Unable to find button locator: %s", str(button_locator)) + continue + if is_present(error_locator): - for element in self.browser.find_elements(error_locator[0], - error_locator[1]): - text = element.text + log.info("Checking for errors") + for errorElement in self.browser.find_elements(error_locator[0], + error_locator[1]): + text = errorElement.text if "Please enter a valid answer" in text: - #print("Error Found") - #print(element.get_attribute('class')) - button = None - break + log.warning("Warning message received: %s", text) + log.info("Attempting to resolve by finding test questions") + + #TODO these questions will need to be logged so that way, individuals can look through the logs and add them at the end of an application run. + #Required question expects an answer. Search through possible questions/answer combos + if is_present(testLabel_locator) and attemptQuestions: + for testLabelElement in self.browser.find_elements(testLabel_locator[0], + testLabel_locator[1]): + try: + log.info("Found test element %s", testLabel_locator) + text = testLabelElement.text + log.info("test element text: %s", text) + #assuming this question is asking if I am authorized to work in the US + if ("Are you" in text and "authorized" in text) or ("Have You" in text and "eduation" in text): + #Be sure to find the child element of the current test question section + yesRadio = testLabelElement.find_element(By.XPATH, yes_locator[1]) + time.sleep(1) + log.info("Attempting to click the radio button for %s", yes_locator) + self.browser.execute_script("arguments[0].click()", yesRadio) + log.info("Clicked the radio button %s", yes_locator) + + #assuming this question is asking if I require sponsorship + if "require" in text and "sponsorship" in text: + noRadio = testLabelElement.find_element(By.XPATH, no_locator[1]) + time.sleep(1) + log.info("Attempting to click the radio button for %s", no_locator) + self.browser.execute_script("arguments[0].click()", noRadio) + log.info("Clicked the radio button %s", no_locator) + + # assuming this question is asking if I require sponsorship + if "you have" in text and "Bachelor's" in text: + yesRadio = testLabelElement.find_element(By.XPATH, yes_locator[1]) + time.sleep(1) + log.info("Attempting to click the radio button for %s", yes_locator) + self.browser.execute_script("arguments[0].click()", yesRadio) + log.info("Clicked the radio button %s", yes_locator) + + #Some questions are asking how many years of experience you have in a specific skill + #Automatically put the number of years that I have worked. + if "How many years" in text and "experience" in text: + textField = testLabelElement.find_element(By.XPATH, textInput_locator[1]) + time.sleep(1) + log.info("Attempting to click the text field for %s", textInput_locator) + self.browser.execute_script("arguments[0].click()", textField) + log.info("Clicked the text field %s", textInput_locator) + time.sleep(1) + log.info("Attempting to send keys to the text field %s", textInput_locator) + textField.send_keys("10") + log.info("Sent keys to the text field %s", textInput_locator) + + + except Exception as e: + log.exception("Could not answer additional questions: %s", e) + log.error("Unable to submit due to error with no solution") + return submitted + attemptQuestions = False + log.info("no longer going to try and answer questions, since we have now tried") + else: + log.error("Unable to submit due to error with no solution") + return submitted + + if button: - button.click() - time.sleep(random.uniform(1.5, 2.5)) - if i in (2, 3): - submitted = True - break - if button == None: - print("Could not complete submission") - break - elif submitted: - print("Application Submitted") - break - - time.sleep(random.uniform(1.5, 2.5)) - - #After submiting the application, a dialog shows up, we need to close this dialog + if button_locator == upload_locater: + log.info("Uploading resume now") + + time.sleep(2) + driver.execute_script("arguments[0].click()", button) + + #TODO This can only handle Chrome right now. Firefox or other browsers will need to be handled separately + # Chrome opens the file browser window with the title "Open" + status = wsh.AppActivate("Open") + log.debug("Able to find file browser dialog: %s", status) + #Must sleep around sending the resume location so it has time to accept all keys submitted + time.sleep(1) + wsh.SendKeys(str(self.resumeloctn)) + time.sleep(1) + wsh.SendKeys("{ENTER}") + + else: + try: + log.info("attempting to click button: %s", str(button_locator)) + response = button.click() + if (button_locator == submit_locater) or (button_locator == submit_application_locator): + log.info("Clicked the submit button. RESPONSE: %s", str(response)) + submitted = True + return submitted + except EC.StaleElementReferenceException: + log.warning("Button was stale. Couldnt click") + + + randoTime = random.uniform(1.5, 2.5) + log.info("Just finished using button %s ; Im going to sleep for %s ;", str(button_locator), randoTime) + time.sleep(randoTime) + + + randoTime = random.uniform(1.5, 2.5) + log.info("Going to sleep after attempting to send resume for %s", randoTime) + time.sleep(randoTime) + + # After submitting the application, a dialog shows up, we need to close this dialog close_button_locator = (By.CSS_SELECTOR, "button[aria-label='Dismiss']") if is_present(close_button_locator): close_button = self.wait.until(EC.element_to_be_clickable(close_button_locator)) close_button.click() except Exception as e: - print(e) - print("cannot apply to this job") - raise(e) + log.info(e) + log.warning("cannot apply to this job") + raise (e) return submitted def load_page(self, sleep=1): scroll_page = 0 while scroll_page < 4000: - self.browser.execute_script("window.scrollTo(0,"+str(scroll_page)+" );") + self.browser.execute_script("window.scrollTo(0," + str(scroll_page) + " );") scroll_page += 200 time.sleep(sleep) @@ -349,20 +472,33 @@ def avoid_lock(self): def next_jobs_page(self, position, location, jobs_per_page): self.browser.get( "https://www.linkedin.com/jobs/search/?f_LF=f_AL&keywords=" + - position + location + "&start="+str(jobs_per_page)) - self.avoid_lock() + position + location + "&start=" + str(jobs_per_page)) + # self.avoid_lock() self.load_page() return (self.browser, jobs_per_page) def finish_apply(self): self.browser.close() +def setupLogger(): + dt = datetime.strftime(datetime.now(), "%m_%d_%y %H_%M_%S ") + logging.basicConfig(filename=('./logs/' + str(dt)+'applyJobs.log'), filemode='w', format='%(name)s::%(levelname)s::%(message)s', datefmt='./logs/%d-%b-%y %H:%M:%S') #TODO need to check if there is a log dir available or not + + log.setLevel(logging.DEBUG) + c_handler = logging.StreamHandler() + c_handler.setLevel(logging.DEBUG) + c_format = logging.Formatter('%(name)s::%(levelname)s::%(lineno)d- %(message)s') + c_handler.setFormatter(c_format) + log.addHandler(c_handler) + if __name__ == '__main__': + setupLogger() # set use of gui (T/F) - useGUI = True - #useGUI = False + #TODO set the GUI flag as a configuration file/settings + #useGUI = True + useGUI = False # use gui if useGUI == True: @@ -370,46 +506,41 @@ def finish_apply(self): app = loginGUI.LoginGUI() app.mainloop() - #get user info info - username=app.frames["StartPage"].username - password=app.frames["StartPage"].password - language=app.frames["PageOne"].language - position=app.frames["PageTwo"].position - location_code=app.frames["PageThree"].location_code + # get user info info + username = app.frames["StartPage"].username + password = app.frames["StartPage"].password + language = app.frames["PageOne"].language + position = app.frames["PageTwo"].position + location_code = app.frames["PageThree"].location_code if location_code == 1: - location=app.frames["PageThree"].location + location = app.frames["PageThree"].location else: location = app.frames["PageFour"].location - resumeloctn=app.frames["PageFive"].resumeloctn + resumeloctn = app.frames["PageFive"].resumeloctn # no gui if useGUI == False: - + #TODO Set the user information as configuration file/settings username = '' password = '' language = 'en' - position = 'marketing' + position = '' location = '' resumeloctn = '' - # print input - print("\nThese is your input:") + # log.info input + log.info("Your input:") - print( - "\nUsername: "+ username, - "\nPassword: "+ password, - "\nLanguage: "+ language, - "\nPosition: "+ position, - "\nLocation: "+ location - ) + log.info( + "\nUsername: %s \nPassword: %s\nLanguage: %s\nPosition: %s\nLocation: %s", username, 'Just Kidding', language, position, location ) - print("\nLet's scrape some jobs!\n") + log.info("Let's scrape some jobs!\n") # get list of already applied jobs filename = 'joblist.csv' try: df = pd.read_csv(filename, header=None) - appliedJobIDs = list (df.iloc[:,1]) + appliedJobIDs = list(df.iloc[:, 1]) except: appliedJobIDs = [] From 450a9d5a39285fd4c1f42d84956ab0e2dbab7910 Mon Sep 17 00:00:00 2001 From: spectrem12 Date: Wed, 15 Jul 2020 00:32:50 -0500 Subject: [PATCH 30/43] Updating requirements and cleaning up some imports. --- easyapplybot.py | 16 +++------------- requirements.txt | 1 + 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 4adcc59..b465a4b 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -1,36 +1,26 @@ import time, random, os, csv, datetime, platform -from selenium import webdriver + from selenium.webdriver.chrome.options import Options from selenium.common.exceptions import TimeoutException from selenium.webdriver.common.keys import Keys -from selenium.common.exceptions import NoSuchElementException from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.relative_locator import RelativeBy -from selenium.webdriver.support.relative_locator import with_tag_name + from bs4 import BeautifulSoup import pandas as pd import pyautogui -from tkinter import filedialog, Tk -import tkinter.messagebox as tm -from urllib.request import urlopen + import loginGUI from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager import json from datetime import datetime import logging -import re -from windowfinder import WindowFinder import win32com.client as comctl wsh =comctl.Dispatch("WScript.Shell") - - - - log = logging.getLogger(__name__) driver = webdriver.Chrome(ChromeDriverManager().install()) diff --git a/requirements.txt b/requirements.txt index acd89fe..e4d8d73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,3 +27,4 @@ six==1.15.0 soupsieve==2.0.1 urllib3==1.25.9 webdriver-manager==3.1.0 +pywin32~=228 From d9f203758bd2c6a2d194083b42b65af3bd3ac3d0 Mon Sep 17 00:00:00 2001 From: spectrem12 Date: Wed, 15 Jul 2020 23:18:10 -0500 Subject: [PATCH 31/43] Minor updates to get apply bot to function. Still having issues with getting all jobs that dont have easy apply button. --- easyapplybot.py | 330 ++++++++++++++++++++++++------------------------ 1 file changed, 168 insertions(+), 162 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index b28896f..6615133 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -5,6 +5,7 @@ from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC +from selenium import webdriver from bs4 import BeautifulSoup import pandas as pd @@ -14,11 +15,13 @@ from webdriver_manager.chrome import ChromeDriverManager import re import yaml +import json from datetime import datetime, timedelta import logging import win32com.client as comctl -wsh =comctl.Dispatch("WScript.Shell") + +wsh = comctl.Dispatch("WScript.Shell") log = logging.getLogger(__name__) driver = webdriver.Chrome(ChromeDriverManager().install()) @@ -115,7 +118,7 @@ def start_apply(self, positions, locations): combo = (position, location) if combo not in combos: combos.append(combo) - print(f"Applying to {position}: {location}") + log.info(f"Applying to {position}: {location}") location = "&location=" + location self.applications_loop(position, location) if len(combos) > 20: @@ -138,12 +141,12 @@ def applications_loop(self, position, location): log.info("Looking for jobs.. Please wait..") while time.time() - start_time < self.MAX_SEARCH_TIME: - log.info(f"{(self.MAX_SEARCH_TIME - (time.time() - start_time))//60} minutes left in this search") + log.warning(f"{(self.MAX_SEARCH_TIME - (time.time() - start_time))//60} minutes left in this search") # sleep to make sure everything loads, add random to make us look human. randoTime = random.uniform(3.5, 6.9) - log.info("Sleeping for %s", randoTime) - time.sleep(randoTime) + log.info("Sleeping for %s", randoTime) + time.sleep(randoTime) self.load_page(sleep=1) # get job links @@ -176,7 +179,7 @@ def applications_loop(self, position, location): if len(jobIDs) == 0 and len(IDs) > 24: jobs_per_page = jobs_per_page + 25 count_job = 0 - #self.avoid_lock() + #self.avoid_lock() #TODO Need t self.browser, jobs_per_page = self.next_jobs_page(position, location, jobs_per_page) @@ -246,7 +249,8 @@ def get_job_page(self, jobID): #root = 'www.linkedin.com' #if root not in job: job = 'https://www.linkedin.com/jobs/view/'+ str(jobID) - log.info("Opening Job Page \n %s", job)self.browser.get(job) + log.info("Opening Job Page \n %s", job) + self.browser.get(job) self.job_page = self.load_page(sleep=0.5) return self.job_page @@ -265,162 +269,164 @@ def get_easy_apply_button(self): def is_jsonable(self, x): - try: - json.dumps(x) - return True - except: - return False + try: + json.dumps(x) + return True + except: + return False - def send_resume(self): + def send_resume(self): def is_present(button_locator): - return len(self.browser.find_elements(button_locator[0], - button_locator[1])) > 0 - - try: - time.sleep(3) - log.info("Attempting to send resume") - #TODO These locators are not future proof. These labels could easily change. Ideally we would search for contained text; - # was unable to get it to work using XPATH and searching for contained text - upload_locater = (By.CSS_SELECTOR, "label[aria-label='DOC, DOCX, PDF formats only (2 MB).']") - next_locater = (By.CSS_SELECTOR, "button[aria-label='Continue to next step']") - review_locater = (By.CSS_SELECTOR, "button[aria-label='Review your application']") - submit_locater = (By.CSS_SELECTOR, "button[aria-label='Submit application']") - submit_application_locator = (By.CSS_SELECTOR, "button[aria-label='Submit application']") - error_locator = (By.CSS_SELECTOR, "p[data-test-form-element-error-message='true']") - cover_letter = (By.CSS_SELECTOR, "input[name='file']") - - testLabel_locator = (By.XPATH, "//span[@data-test-form-element-label-title='true']") - yes_locator = (By.XPATH, "//input[@value='Yes']") - no_locator = (By.XPATH, "//input[@value='No']") - textInput_locator = (By.XPATH, "//div[@data-test-single-line-text-input-wrap='true']") - - - submitted = False - attemptQuestions = True - while not submitted: - button = None - - # Upload Cover Letter if possible - if is_present(cover_letter): - input_button = self.browser.find_elements(cover_letter[0], - cover_letter[1]) - - input_button[0].send_keys(self.cover_letter_loctn) - time.sleep(random.uniform(4.5, 6.5)) - - for i, button_locator in enumerate( - [upload_locater, next_locater, review_locater, submit_locater, submit_application_locator]): - - log.info("Searching for button locator: %s", str(button_locator)) - if is_present(button_locator): - log.info("button found with this locator: %s", str(button_locator)) - button = self.wait.until(EC.element_to_be_clickable(button_locator)) - else: - log.info("Unable to find button locator: %s", str(button_locator)) - continue - - if is_present(error_locator): - log.info("Checking for errors") - for errorElement in self.browser.find_elements(error_locator[0], - error_locator[1]): - text = errorElement.text - if "Please enter a valid answer" in text: - log.warning("Warning message received: %s", text) - log.info("Attempting to resolve by finding test questions") - - #TODO these questions will need to be logged so that way, individuals can look through the logs and add them at the end of an application run. - #Required question expects an answer. Search through possible questions/answer combos - if is_present(testLabel_locator) and attemptQuestions: - for testLabelElement in self.browser.find_elements(testLabel_locator[0], - testLabel_locator[1]): - try: - log.info("Found test element %s", testLabel_locator) - text = testLabelElement.text - log.info("test element text: %s", text) - #assuming this question is asking if I am authorized to work in the US - if ("Are you" in text and "authorized" in text) or ("Have You" in text and "eduation" in text): - #Be sure to find the child element of the current test question section - yesRadio = testLabelElement.find_element(By.XPATH, yes_locator[1]) - time.sleep(1) - log.info("Attempting to click the radio button for %s", yes_locator) - self.browser.execute_script("arguments[0].click()", yesRadio) - log.info("Clicked the radio button %s", yes_locator) - - #assuming this question is asking if I require sponsorship - if "require" in text and "sponsorship" in text: - noRadio = testLabelElement.find_element(By.XPATH, no_locator[1]) - time.sleep(1) - log.info("Attempting to click the radio button for %s", no_locator) - self.browser.execute_script("arguments[0].click()", noRadio) - log.info("Clicked the radio button %s", no_locator) - - # assuming this question is asking if I require sponsorship - if "you have" in text and "Bachelor's" in text: - yesRadio = testLabelElement.find_element(By.XPATH, yes_locator[1]) - time.sleep(1) - log.info("Attempting to click the radio button for %s", yes_locator) - self.browser.execute_script("arguments[0].click()", yesRadio) - log.info("Clicked the radio button %s", yes_locator) - - #Some questions are asking how many years of experience you have in a specific skill - #Automatically put the number of years that I have worked. - if "How many years" in text and "experience" in text: - textField = testLabelElement.find_element(By.XPATH, textInput_locator[1]) - time.sleep(1) - log.info("Attempting to click the text field for %s", textInput_locator) - self.browser.execute_script("arguments[0].click()", textField) - log.info("Clicked the text field %s", textInput_locator) - time.sleep(1) - log.info("Attempting to send keys to the text field %s", textInput_locator) - textField.send_keys("10") - log.info("Sent keys to the text field %s", textInput_locator) - - - except Exception as e: - log.exception("Could not answer additional questions: %s", e) - log.error("Unable to submit due to error with no solution") - return submitted - attemptQuestions = False - log.info("no longer going to try and answer questions, since we have now tried") - else: - log.error("Unable to submit due to error with no solution") - return submitted - - - if button: - if button_locator == upload_locater: - log.info("Uploading resume now") - - time.sleep(2) - driver.execute_script("arguments[0].click()", button) - - #TODO This can only handle Chrome right now. Firefox or other browsers will need to be handled separately - # Chrome opens the file browser window with the title "Open" - status = wsh.AppActivate("Open") - log.debug("Able to find file browser dialog: %s", status) - #Must sleep around sending the resume location so it has time to accept all keys submitted - time.sleep(1) - wsh.SendKeys(str(self.resumeloctn)) - time.sleep(1) - wsh.SendKeys("{ENTER}") - - else: - try: - log.info("attempting to click button: %s", str(button_locator)) - response = button.click() - if (button_locator == submit_locater) or (button_locator == submit_application_locator): - log.info("Clicked the submit button. RESPONSE: %s", str(response)) - submitted = True - return submitted - except EC.StaleElementReferenceException: - log.warning("Button was stale. Couldnt click") - - - randoTime = random.uniform(1.5, 2.5) - log.info("Just finished using button %s ; Im going to sleep for %s ;", str(button_locator), randoTime) - time.sleep(randoTime) - - # After submitting the application, a dialog shows up, we need to close this dialog + return (len(self.browser.find_elements(button_locator[0], button_locator[1])) > 0) + + try: + time.sleep(3) + log.info("Attempting to send resume") + #TODO These locators are not future proof. These labels could easily change. Ideally we would search for contained text; + # was unable to get it to work using XPATH and searching for contained text + upload_locater = (By.CSS_SELECTOR, "label[aria-label='DOC, DOCX, PDF formats only (2 MB).']") + next_locater = (By.CSS_SELECTOR, "button[aria-label='Continue to next step']") + review_locater = (By.CSS_SELECTOR, "button[aria-label='Review your application']") + submit_locater = (By.CSS_SELECTOR, "button[aria-label='Submit application']") + submit_application_locator = (By.CSS_SELECTOR, "button[aria-label='Submit application']") + error_locator = (By.CSS_SELECTOR, "p[data-test-form-element-error-message='true']") + cover_letter = (By.CSS_SELECTOR, "input[name='file']") + + testLabel_locator = (By.XPATH, "//span[@data-test-form-element-label-title='true']") + yes_locator = (By.XPATH, "//input[@value='Yes']") + no_locator = (By.XPATH, "//input[@value='No']") + textInput_locator = (By.XPATH, "//div[@data-test-single-line-text-input-wrap='true']") + + + submitted = False + attemptQuestions = True + while not submitted: + button = None + + # Upload Cover Letter if possible + if is_present(cover_letter): + input_button = self.browser.find_elements(cover_letter[0], + cover_letter[1]) + + input_button[0].send_keys(self.cover_letter_loctn) + time.sleep(random.uniform(4.5, 6.5)) + + for i, button_locator in enumerate( + [upload_locater, next_locater, review_locater, submit_locater, submit_application_locator]): + + log.info("Searching for button locator: %s", str(button_locator)) + if is_present(button_locator): + log.info("button found with this locator: %s", str(button_locator)) + button = self.wait.until(EC.element_to_be_clickable(button_locator)) + else: + log.info("Unable to find button locator: %s", str(button_locator)) + continue + + if is_present(error_locator): + log.info("Checking for errors") + for errorElement in self.browser.find_elements(error_locator[0], + error_locator[1]): + text = errorElement.text + if "Please enter a valid answer" in text: + log.warning("Warning message received: %s", text) + log.info("Attempting to resolve by finding test questions") + + #TODO these questions will need to be logged so that way, individuals can look through the logs and add them at the end of an application run. + #Required question expects an answer. Search through possible questions/answer combos + if is_present(testLabel_locator) and attemptQuestions: + for testLabelElement in self.browser.find_elements(testLabel_locator[0], + testLabel_locator[1]): + try: + log.info("Found test element %s", testLabel_locator) + text = testLabelElement.text + log.info("test element text: %s", text) + #assuming this question is asking if I am authorized to work in the US + if ("Are you" in text and "authorized" in text) or ("Have You" in text and "eduation" in text): + #Be sure to find the child element of the current test question section + yesRadio = testLabelElement.find_element(By.XPATH, yes_locator[1]) + time.sleep(1) + log.info("Attempting to click the radio button for %s", yes_locator) + self.browser.execute_script("arguments[0].click()", yesRadio) + log.info("Clicked the radio button %s", yes_locator) + + #assuming this question is asking if I require sponsorship + if "require" in text and "sponsorship" in text: + noRadio = testLabelElement.find_element(By.XPATH, no_locator[1]) + time.sleep(1) + log.info("Attempting to click the radio button for %s", no_locator) + self.browser.execute_script("arguments[0].click()", noRadio) + log.info("Clicked the radio button %s", no_locator) + + # assuming this question is asking if I require sponsorship + if "you have" in text and "Bachelor's" in text: + yesRadio = testLabelElement.find_element(By.XPATH, yes_locator[1]) + time.sleep(1) + log.info("Attempting to click the radio button for %s", yes_locator) + self.browser.execute_script("arguments[0].click()", yesRadio) + log.info("Clicked the radio button %s", yes_locator) + + #Some questions are asking how many years of experience you have in a specific skill + #Automatically put the number of years that I have worked. + if "How many years" in text and "experience" in text: + textField = testLabelElement.find_element(By.XPATH, textInput_locator[1]) + time.sleep(1) + log.info("Attempting to click the text field for %s", textInput_locator) + self.browser.execute_script("arguments[0].click()", textField) + log.info("Clicked the text field %s", textInput_locator) + time.sleep(1) + log.info("Attempting to send keys to the text field %s", textInput_locator) + textField.send_keys("10") + log.info("Sent keys to the text field %s", textInput_locator) + + + except Exception as e: + log.exception("Could not answer additional questions: %s", e) + log.error("Unable to submit due to error with no solution") + return submitted + attemptQuestions = False + log.info("no longer going to try and answer questions, since we have now tried") + else: + log.error("Unable to submit due to error with no solution") + return submitted + + + if button: + if button_locator == upload_locater: + log.info("Uploading resume now") + + time.sleep(2) + driver.execute_script("arguments[0].click()", button) + + #TODO This can only handle Chrome right now. Firefox or other browsers will need to be handled separately + # Chrome opens the file browser window with the title "Open" + status = wsh.AppActivate("Open") + log.debug("Able to find file browser dialog: %s", status) + #Must sleep around sending the resume location so it has time to accept all keys submitted + time.sleep(1) + wsh.SendKeys(str(self.resumeloctn)) + time.sleep(1) + wsh.SendKeys("{ENTER}") + + else: + try: + log.info("attempting to click button: %s", str(button_locator)) + response = button.click() + if (button_locator == submit_locater) or (button_locator == submit_application_locator): + log.info("Clicked the submit button. RESPONSE: %s", str(response)) + submitted = True + return submitted + except EC.StaleElementReferenceException: + log.warning("Button was stale. Couldnt click") + else: + if (button_locator == submit_locater) or (button_locator == submit_application_locator): + log.warning("Unable to submit. It appears none of the buttons were found.") + break + + randoTime = random.uniform(1.5, 2.5) + log.info("Just finished using button %s ; Im going to sleep for %s ;", str(button_locator), randoTime) + time.sleep(randoTime) + + # After submitting the application, a dialog shows up, we need to close this dialog close_button_locator = (By.CSS_SELECTOR, "button[aria-label='Dismiss']") if is_present(close_button_locator): close_button = self.wait.until(EC.element_to_be_clickable(close_button_locator)) @@ -482,7 +488,7 @@ def setupLogger(): if __name__ == '__main__': - setupLogger() + setupLogger() with open("config.yaml", 'r') as stream: try: From 4c6ff1b09fd1f86b16168b3bbb321873982f57f9 Mon Sep 17 00:00:00 2001 From: spectrem12 Date: Thu, 16 Jul 2020 00:55:24 -0500 Subject: [PATCH 32/43] Added checks to see if files are created or not to avoid throwing exceptions. Add checks to see if apply button is redirecting you. Add more detail logging for readibility and understanding. --- easyapplybot.py | 70 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 6615133..df9443e 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -31,11 +31,8 @@ class EasyApplyBot: - MAX_SEARCH_TIME = 10*60 - - def __init__(self, username, password, @@ -105,7 +102,7 @@ def fill_data(self): self.browser.set_window_size(0, 0) self.browser.set_window_position(2000, 2000) - log.info(self.cover_letter_loctn) + def start_apply(self, positions, locations): start = time.time() @@ -179,24 +176,42 @@ def applications_loop(self, position, location): if len(jobIDs) == 0 and len(IDs) > 24: jobs_per_page = jobs_per_page + 25 count_job = 0 - #self.avoid_lock() #TODO Need t + # TODO avoid lock function disabled during debugging. + # Turn back on with major release and running while sleeping. + #self.avoid_lock() self.browser, jobs_per_page = self.next_jobs_page(position, location, jobs_per_page) # loop over IDs to apply for i, jobID in enumerate(jobIDs): count_job += 1 - self.get_job_page(jobID) + tabs = len(self.browser.window_handles) + job, jobPage = self.get_job_page(jobID) # get easy apply button button = self.get_easy_apply_button() if button is not False: + log.info("It appears that the apply button is considered an EASY apply") + #TODO Need to confirm that its an easy apply button by checking the URL is still linkedin URL and not a redirect string_easy = "* has Easy Apply Button" + log.info("Clicking the EASY apply button") button.click() + log.info("Wait for page to load") time.sleep(3) - result = self.send_resume() - count_application += 1 + log.info("Checking to see if the current URL is the same as the job URL") + log.info(self.browser.current_url) + log.info(job) + newTabs = len(self.browser.window_handles) + if self.browser.current_url == job and (newTabs == tabs): + log.info("The URLs match; Attempting to apply") + result = self.send_resume() + count_application += 1 + else: + log.info("The URLs do not match") + string_easy = "* Doesn't have Easy Apply Button" + result = False else: + log.info("The button does not exist.") string_easy = "* Doesn't have Easy Apply Button" result = False @@ -248,11 +263,11 @@ def re_extract(text, pattern): def get_job_page(self, jobID): #root = 'www.linkedin.com' #if root not in job: - job = 'https://www.linkedin.com/jobs/view/'+ str(jobID) + job = 'https://www.linkedin.com/jobs/view/'+ str(jobID) + '/' log.info("Opening Job Page \n %s", job) self.browser.get(job) self.job_page = self.load_page(sleep=0.5) - return self.job_page + return job, self.job_page def get_easy_apply_button(self): @@ -403,7 +418,7 @@ def is_present(button_locator): log.debug("Able to find file browser dialog: %s", status) #Must sleep around sending the resume location so it has time to accept all keys submitted time.sleep(1) - wsh.SendKeys(str(self.resumeloctn)) + wsh.SendKeys(str(self.resume_loctn)) time.sleep(1) wsh.SendKeys("{ENTER}") @@ -476,15 +491,19 @@ def finish_apply(self): self.browser.close() def setupLogger(): - dt = datetime.strftime(datetime.now(), "%m_%d_%y %H_%M_%S ") - logging.basicConfig(filename=('./logs/' + str(dt)+'applyJobs.log'), filemode='w', format='%(name)s::%(levelname)s::%(message)s', datefmt='./logs/%d-%b-%y %H:%M:%S') #TODO need to check if there is a log dir available or not + dt = datetime.strftime(datetime.now(), "%m_%d_%y %H_%M_%S ") + + if not os.path.isdir('./logs'): + os.mkdir('./logs') + + logging.basicConfig(filename=('./logs/' + str(dt)+'applyJobs.log'), filemode='w', format='%(name)s::%(levelname)s::%(message)s', datefmt='./logs/%d-%b-%y %H:%M:%S') #TODO need to check if there is a log dir available or not - log.setLevel(logging.DEBUG) - c_handler = logging.StreamHandler() - c_handler.setLevel(logging.DEBUG) - c_format = logging.Formatter('%(name)s::%(levelname)s::%(lineno)d- %(message)s') - c_handler.setFormatter(c_format) - log.addHandler(c_handler) + log.setLevel(logging.DEBUG) + c_handler = logging.StreamHandler() + c_handler.setLevel(logging.DEBUG) + c_format = logging.Formatter('%(name)s::%(levelname)s::%(lineno)d- %(message)s') + c_handler.setFormatter(c_format) + log.addHandler(c_handler) if __name__ == '__main__': @@ -503,9 +522,16 @@ def setupLogger(): print(parameters) - cover_letter_loctn = parameters.get('cover_letter_loctn', [None])[0] - output_filename = parameters.get('output_filename', ['output.csv'])[0] - blacklist = parameters.get('blacklist', []) + resume_loctn = parameters.get('resume_loctn') + cover_letter_loctn = parameters.get('cover_letter_loctn') + output_filename = parameters.get('output_filename') + blacklist = parameters.get('blacklist') + + #default to output file if nothing was given. + if output_filename == [None]: + output_filename = "./output.csv" + if not os.path.exists(output_filename): + with open(output_filename, 'w+'): pass bot = EasyApplyBot(parameters['username'], parameters['password'], From 984c971fc3bd90bc380e99f857c31980d33fadb2 Mon Sep 17 00:00:00 2001 From: spectrem12 Date: Thu, 16 Jul 2020 02:10:13 -0500 Subject: [PATCH 33/43] Added checks to see if files are created or not to avoid throwing exceptions. Add checks to see if apply button is redirecting you. Add more detail logging for readibility and understanding. --- easyapplybot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/easyapplybot.py b/easyapplybot.py index df9443e..ce7105f 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -322,7 +322,7 @@ def is_present(button_locator): if is_present(cover_letter): input_button = self.browser.find_elements(cover_letter[0], cover_letter[1]) - + #TODO is this cover letter the same thing as the resume upload locator? input_button[0].send_keys(self.cover_letter_loctn) time.sleep(random.uniform(4.5, 6.5)) From 8d006f2d073b76fe985a8f0a6ae63146f0436daa Mon Sep 17 00:00:00 2001 From: krapes Date: Thu, 16 Jul 2020 16:13:53 -0400 Subject: [PATCH 34/43] Feature: Better support for cases where a line for output file exists in config.yaml but does not contain anything --- easyapplybot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/easyapplybot.py b/easyapplybot.py index d1a35ac..f1a00d0 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -383,7 +383,8 @@ def finish_apply(self): print(parameters) cover_letter_loctn = parameters.get('cover_letter_loctn', [None])[0] - output_filename = parameters.get('output_filename', ['output.csv'])[0] + output_filename = [f for f in parameters.get('output_filename', ['output.csv']) if f != None] + output_filename = output_filename[0] if len(output_filename) > 0 else 'output.csv' blacklist = parameters.get('blacklist', []) bot = EasyApplyBot(parameters['username'], From facdc5868c765e1459442a11888ca7c5fad1e773 Mon Sep 17 00:00:00 2001 From: Spectrem12 Date: Thu, 16 Jul 2020 17:56:19 -0500 Subject: [PATCH 35/43] Update easyapplybot.py --- easyapplybot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index ce7105f..2027577 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -356,7 +356,7 @@ def is_present(button_locator): text = testLabelElement.text log.info("test element text: %s", text) #assuming this question is asking if I am authorized to work in the US - if ("Are you" in text and "authorized" in text) or ("Have You" in text and "eduation" in text): + if ("Are you" in text and "authorized" in text) or ("Have You" in text and "education" in text): #Be sure to find the child element of the current test question section yesRadio = testLabelElement.find_element(By.XPATH, yes_locator[1]) time.sleep(1) @@ -372,7 +372,7 @@ def is_present(button_locator): self.browser.execute_script("arguments[0].click()", noRadio) log.info("Clicked the radio button %s", no_locator) - # assuming this question is asking if I require sponsorship + # assuming this question is asking if I have a Bachelor's degree if "you have" in text and "Bachelor's" in text: yesRadio = testLabelElement.find_element(By.XPATH, yes_locator[1]) time.sleep(1) @@ -542,4 +542,4 @@ def setupLogger(): locations = [l for l in parameters['locations'] if l != None] positions = [p for p in parameters['positions'] if p != None] - bot.start_apply(positions, locations) \ No newline at end of file + bot.start_apply(positions, locations) From d93501f93c03cc33badb3df4bf9f3c2ebba5b217 Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 17 Jul 2020 15:17:07 -0400 Subject: [PATCH 36/43] Feature: First draft of photo upload working --- easyapplybot.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index f1a00d0..417006a 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -25,7 +25,7 @@ class EasyApplyBot: MAX_SEARCH_TIME = 10*60 - + photo = '/home/kerri/Pictures/surfing.jpg' def __init__(self, username, @@ -164,7 +164,7 @@ def applications_loop(self, position, location): jobIDs = [x for x in IDs if x not in self.appliedJobIDs] after = len(jobIDs) - + jobIDs = [1943232413] if len(jobIDs) == 0 and len(IDs) > 24: jobs_per_page = jobs_per_page + 25 count_job = 0 @@ -275,17 +275,31 @@ def is_present(button_locator): "button[aria-label='Submit application']") error_locator = (By.CSS_SELECTOR, "p[data-test-form-element-error-message='true']") - cover_letter = (By.CSS_SELECTOR, "input[name='file']") + upload_locator = (By.CSS_SELECTOR, "input[name='file']") + + submitted = False while True: # Upload Cover Letter if possible - if is_present(cover_letter): - input_button = self.browser.find_elements(cover_letter[0], - cover_letter[1]) - - input_button[0].send_keys(self.cover_letter_loctn) + if is_present(upload_locator): + + input_buttons = self.browser.find_elements(upload_locator[0], + upload_locator[1]) + for input_button in input_buttons: + parent = input_button.find_element(By.XPATH, "..") + sibling = parent.find_element(By.XPATH, "preceding-sibling::*") + print(sibling) + print(sibling.text) + if 'Photo' in sibling.text: + input_button.send_keys(self.photo) + + grandparent = sibling.find_element(By.XPATH, "..") + print(grandparent) + print(grandparent.text) + + #input_button[0].send_keys(self.cover_letter_loctn) time.sleep(random.uniform(4.5, 6.5)) # Click Next or submitt button if possible From 288d9a6960f1d6917bae8b168539be8c66f98261 Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 17 Jul 2020 17:11:53 -0400 Subject: [PATCH 37/43] Feature: uploads dict working --- easyapplybot.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 417006a..0dd2ced 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -25,12 +25,12 @@ class EasyApplyBot: MAX_SEARCH_TIME = 10*60 - photo = '/home/kerri/Pictures/surfing.jpg' + #photo = '/home/kerri/Pictures/surfing.jpg' def __init__(self, username, password, - cover_letter_loctn=None, + uploads={}, filename='output.csv', blacklist=[]): @@ -38,7 +38,8 @@ def __init__(self, dirpath = os.getcwd() print("current directory is : " + dirpath) - self.cover_letter_loctn = cover_letter_loctn + #self.cover_letter_loctn = cover_letter_loctn + self.uploads = uploads self.appliedJobIDs = self.get_appliedIDs(filename) if self.get_appliedIDs(filename) != None else [] self.filename = filename self.options = self.browser_options() @@ -96,7 +97,6 @@ def fill_data(self): self.browser.set_window_size(0, 0) self.browser.set_window_position(2000, 2000) - print(self.cover_letter_loctn) def start_apply(self, positions, locations): start = time.time() @@ -164,7 +164,6 @@ def applications_loop(self, position, location): jobIDs = [x for x in IDs if x not in self.appliedJobIDs] after = len(jobIDs) - jobIDs = [1943232413] if len(jobIDs) == 0 and len(IDs) > 24: jobs_per_page = jobs_per_page + 25 count_job = 0 @@ -290,14 +289,11 @@ def is_present(button_locator): for input_button in input_buttons: parent = input_button.find_element(By.XPATH, "..") sibling = parent.find_element(By.XPATH, "preceding-sibling::*") - print(sibling) - print(sibling.text) - if 'Photo' in sibling.text: - input_button.send_keys(self.photo) - grandparent = sibling.find_element(By.XPATH, "..") - print(grandparent) - print(grandparent.text) + for key in self.uploads.keys(): + if key in sibling.text or key in grandparent.text: + input_button.send_keys(self.uploads[key]) + #input_button[0].send_keys(self.cover_letter_loctn) time.sleep(random.uniform(4.5, 6.5)) @@ -381,6 +377,9 @@ def next_jobs_page(self, position, location, jobs_per_page): def finish_apply(self): self.browser.close() + + + if __name__ == '__main__': with open("config.yaml", 'r') as stream: @@ -396,14 +395,17 @@ def finish_apply(self): print(parameters) - cover_letter_loctn = parameters.get('cover_letter_loctn', [None])[0] + #cover_letter_loctn = parameters.get('cover_letter_loctn', [None])[0] output_filename = [f for f in parameters.get('output_filename', ['output.csv']) if f != None] output_filename = output_filename[0] if len(output_filename) > 0 else 'output.csv' blacklist = parameters.get('blacklist', []) + uploads = parameters.get('uploads', {}) + for key in uploads.keys(): + assert uploads[key] != None bot = EasyApplyBot(parameters['username'], parameters['password'], - cover_letter_loctn=cover_letter_loctn, + uploads=uploads, filename=output_filename, blacklist=blacklist ) From 9506c7d7226becad7023dd4f50bcdb167e1abb31 Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 17 Jul 2020 17:12:58 -0400 Subject: [PATCH 38/43] Feature: update config --- config.yaml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/config.yaml b/config.yaml index 655292f..af5b02b 100644 --- a/config.yaml +++ b/config.yaml @@ -2,19 +2,23 @@ username: password: positions: -- # positions you want to search for +- Data Scientist - # Another position you want to search for - # A third position you want to search for locations: -- # Location you want to search for +- Remote - # A second location you want to search in -cover_letter_loctn: -- # '/home/PATH_TO_FILE' +# --------- Optional Parameters ------- +# uploads: +# Resume: # PATH TO Resume +# Cover Letter: # PATH TO cover letter +# Photo: # PATH TO photo -output_filename: -- # PATH TO OUTPUT FILE (default output.csv) -blacklist: -- # Company names you want to ignore \ No newline at end of file +# output_filename: +# - # PATH TO OUTPUT FILE (default output.csv) + +# blacklist: +# - # Company names you want to ignore \ No newline at end of file From 076a08d95f7906b56375dc491f1c2aba4e1f11ff Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 17 Jul 2020 17:37:08 -0400 Subject: [PATCH 39/43] Refactor --- easyapplybot.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 0dd2ced..7c90ee8 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -25,7 +25,6 @@ class EasyApplyBot: MAX_SEARCH_TIME = 10*60 - #photo = '/home/kerri/Pictures/surfing.jpg' def __init__(self, username, @@ -38,9 +37,9 @@ def __init__(self, dirpath = os.getcwd() print("current directory is : " + dirpath) - #self.cover_letter_loctn = cover_letter_loctn self.uploads = uploads - self.appliedJobIDs = self.get_appliedIDs(filename) if self.get_appliedIDs(filename) != None else [] + past_ids = self.get_appliedIDs(filename) + self.appliedJobIDs = past_ids if past_ids != None else [] self.filename = filename self.options = self.browser_options() self.browser = driver @@ -50,13 +49,12 @@ def __init__(self, def get_appliedIDs(self, filename): - print(filename) try: df = pd.read_csv(filename, header=None, names=['timestamp', 'jobID', 'job', 'company', 'attempted', 'result'], lineterminator='\n', - encoding = 'utf-8') + encoding='utf-8') df['timestamp'] = pd.to_datetime(df['timestamp'], format="%Y-%m-%d %H:%M:%S.%f") df = df[df['timestamp'] > (datetime.now() - timedelta(days=2))] @@ -92,12 +90,10 @@ def start_linkedin(self,username,password): except TimeoutException: print("TimeoutException! Username/password field or login button not found") - def fill_data(self): self.browser.set_window_size(0, 0) self.browser.set_window_position(2000, 2000) - def start_apply(self, positions, locations): start = time.time() self.fill_data() @@ -235,8 +231,7 @@ def re_extract(text, pattern): def get_job_page(self, jobID): - #root = 'www.linkedin.com' - #if root not in job: + job = 'https://www.linkedin.com/jobs/view/'+ str(jobID) self.browser.get(job) self.job_page = self.load_page(sleep=0.5) @@ -263,7 +258,6 @@ def is_present(button_locator): try: time.sleep(random.uniform(1.5, 2.5)) - #print(f"Navigating... ") next_locater = (By.CSS_SELECTOR, "button[aria-label='Continue to next step']") review_locater = (By.CSS_SELECTOR, @@ -395,7 +389,7 @@ def finish_apply(self): print(parameters) - #cover_letter_loctn = parameters.get('cover_letter_loctn', [None])[0] + output_filename = [f for f in parameters.get('output_filename', ['output.csv']) if f != None] output_filename = output_filename[0] if len(output_filename) > 0 else 'output.csv' blacklist = parameters.get('blacklist', []) From 4a32c68a4b6f6fcd578eac0bc7c4e153a762855d Mon Sep 17 00:00:00 2001 From: krapes Date: Fri, 17 Jul 2020 17:48:09 -0400 Subject: [PATCH 40/43] Documentation: update README --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e5e0206..310aa7b 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,10 @@ locations: - # Location you want to search for - # A second location you want to search in -cover_letter_loctn: -- # '/home/PATH_TO_FILE' +uploads: + Resume: # PATH TO Resume + Cover Letter: # PATH TO cover letter + Photo: # PATH TO photo output_filename: - # PATH TO OUTPUT FILE (default output.csv) @@ -37,6 +39,11 @@ blacklist: ``` __NOTE: AFTER EDITING SAVE FILE, DO NOT COMMIT FILE__ +### Uploads + +There is no limit to the number of files you can list in the uploads section. +The program takes the titles from the input boxes and tries to match them with +list in the config file. ## Execute From 127bc61b8dac984b0899848332623b1b7643a2d2 Mon Sep 17 00:00:00 2001 From: Kerri Rapes Date: Sun, 19 Jul 2020 10:09:09 -0400 Subject: [PATCH 41/43] Create stale.yml Create workflow to tag stale issues --- .github/workflows/stale.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..3404517 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,19 @@ +name: Mark stale issues and pull requests + +on: + schedule: + - cron: "30 1 * * *" + +jobs: + stale: + + runs-on: ubuntu-latest + + steps: + - uses: actions/stale@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'Stale issue message' + stale-pr-message: 'Stale pull request message' + stale-issue-label: 'no-issue-activity' + stale-pr-label: 'no-pr-activity' From 5dcaa83f23a85d8e22dc88e3e82103ad4a381d6b Mon Sep 17 00:00:00 2001 From: spectrem12 Date: Tue, 21 Jul 2020 22:01:32 -0500 Subject: [PATCH 42/43] Updated updating text boxes for additional questions. And added question for languages spoken for English. --- easyapplybot.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index ce7105f..8ac15b5 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -79,6 +79,10 @@ def browser_options(self): options.add_argument("--ignore-certificate-errors") options.add_argument('--no-sandbox') options.add_argument("--disable-extensions") + + #Disable webdriver flags or you will be easily detectable + options.add_argument("--disable-blink-features") + options.add_argument("--disable-blink-features=AutomationControlled") return options def start_linkedin(self,username,password): @@ -310,7 +314,7 @@ def is_present(button_locator): testLabel_locator = (By.XPATH, "//span[@data-test-form-element-label-title='true']") yes_locator = (By.XPATH, "//input[@value='Yes']") no_locator = (By.XPATH, "//input[@value='No']") - textInput_locator = (By.XPATH, "//div[@data-test-single-line-text-input-wrap='true']") + textInput_locator = (By.XPATH, "//input[@type='text']") submitted = False @@ -393,6 +397,23 @@ def is_present(button_locator): textField.send_keys("10") log.info("Sent keys to the text field %s", textInput_locator) + #This should be updated to match the language you speak. + if "Do you" in text and "speak" in text: + if "English" in text: + yesRadio = testLabelElement.find_element(By.XPATH, yes_locator[1]) + time.sleep(1) + log.info("Attempting to click the radio button for %s", yes_locator) + self.browser.execute_script("arguments[0].click()", yesRadio) + log.info("Clicked the radio button %s", yes_locator) + #if not english then say no. + else: + noRadio = testLabelElement.find_element(By.XPATH, no_locator[1]) + time.sleep(1) + log.info("Attempting to click the radio button for %s", no_locator) + self.browser.execute_script("arguments[0].click()", noRadio) + log.info("Clicked the radio button %s", no_locator) + + except Exception as e: log.exception("Could not answer additional questions: %s", e) @@ -531,7 +552,9 @@ def setupLogger(): if output_filename == [None]: output_filename = "./output.csv" if not os.path.exists(output_filename): - with open(output_filename, 'w+'): pass + with open(output_filename, 'w+') as f: + writer = csv.writer(f) + writer.writerow(['DateTime', 'JobID', 'Title', 'Company', 'Attempted', 'Success']) bot = EasyApplyBot(parameters['username'], parameters['password'], From c7a81fbc3881101721dbaaada9a8426dd1ce92e4 Mon Sep 17 00:00:00 2001 From: spectrem12 Date: Tue, 21 Jul 2020 22:26:09 -0500 Subject: [PATCH 43/43] Fixed merge issues. Added TODOs --- easyapplybot.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/easyapplybot.py b/easyapplybot.py index 5e93cbf..d0d67d1 100644 --- a/easyapplybot.py +++ b/easyapplybot.py @@ -30,6 +30,7 @@ # pyinstaller --onefile --windowed --icon=app.ico easyapplybot.py class EasyApplyBot: + MAX_SEARCH_TIME = 30 * 60 def __init__(self, username, @@ -261,7 +262,7 @@ def re_extract(text, pattern): def get_job_page(self, jobID): - job = 'https://www.linkedin.com/jobs/view/'+ str(jobID) + job = 'https://www.linkedin.com/jobs/view/'+ str(jobID) + '/' self.browser.get(job) self.job_page = self.load_page(sleep=0.5) @@ -298,10 +299,10 @@ def is_present(button_locator): log.info("Attempting to send resume") #TODO These locators are not future proof. These labels could easily change. Ideally we would search for contained text; # was unable to get it to work using XPATH and searching for contained text - upload_locater = (By.CSS_SELECTOR, "label[aria-label='DOC, DOCX, PDF formats only (2 MB).']") - next_locater = (By.CSS_SELECTOR, "button[aria-label='Continue to next step']") - review_locater = (By.CSS_SELECTOR, "button[aria-label='Review your application']") - submit_locater = (By.CSS_SELECTOR, "button[aria-label='Submit application']") + upload_locator = (By.CSS_SELECTOR, "label[aria-label='DOC, DOCX, PDF formats only (2 MB).']") + next_locator = (By.CSS_SELECTOR, "button[aria-label='Continue to next step']") + review_locator = (By.CSS_SELECTOR, "button[aria-label='Review your application']") + submit_locator = (By.CSS_SELECTOR, "button[aria-label='Submit application']") submit_application_locator = (By.CSS_SELECTOR, "button[aria-label='Submit application']") error_locator = (By.CSS_SELECTOR, "p[data-test-form-element-error-message='true']") cover_letter = (By.CSS_SELECTOR, "input[name='file']") @@ -338,7 +339,7 @@ def is_present(button_locator): time.sleep(random.uniform(4.5, 6.5)) for i, button_locator in enumerate( - [upload_locater, next_locater, review_locater, submit_locater, submit_application_locator]): + [upload_locator, next_locator, review_locator, submit_locator, submit_application_locator]): log.info("Searching for button locator: %s", str(button_locator)) if is_present(button_locator): @@ -391,6 +392,8 @@ def is_present(button_locator): self.browser.execute_script("arguments[0].click()", yesRadio) log.info("Clicked the radio button %s", yes_locator) + #TODO Issue where if there are multiple lines that ask for number of years experience then years experience will be written twice + #TODO Need to add a configuration file with all the answer for these questions versus having them hardcoded. #Some questions are asking how many years of experience you have in a specific skill #Automatically put the number of years that I have worked. if "How many years" in text and "experience" in text: @@ -434,7 +437,7 @@ def is_present(button_locator): if button: - if button_locator == upload_locater: + if button_locator == upload_locator: log.info("Uploading resume now") time.sleep(2) @@ -454,14 +457,14 @@ def is_present(button_locator): try: log.info("attempting to click button: %s", str(button_locator)) response = button.click() - if (button_locator == submit_locater) or (button_locator == submit_application_locator): + if (button_locator == submit_locator) or (button_locator == submit_application_locator): log.info("Clicked the submit button. RESPONSE: %s", str(response)) submitted = True return submitted except EC.StaleElementReferenceException: log.warning("Button was stale. Couldnt click") else: - if (button_locator == submit_locater) or (button_locator == submit_application_locator): + if (button_locator == submit_locator) or (button_locator == submit_application_locator): log.warning("Unable to submit. It appears none of the buttons were found.") break