diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..30404ce --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +TODO \ No newline at end of file diff --git a/connection_automator/bot_controller.py b/connection_automator/bot_controller.py new file mode 100644 index 0000000..738a56d --- /dev/null +++ b/connection_automator/bot_controller.py @@ -0,0 +1,99 @@ +""" +The Bot Controller module of the LinkedIn Connection Automation Bot. + +This module coordinates the overall execution of the LinkedIn Bot's Tasks. +""" +import time +import random +import pandas as pd +from selenium.common.exceptions import WebDriverException +from connection_automator.linkedin_bot import LinkedinBot +import consts as c + +class BotController(): + """ + This class coordinates the overall execution of the LinkedIn bot. + """ + + def __init__(self): + """ + Initializes the bot controller. + """ + self.min_connections = c.MINIMUM_CONNECTION_COUNT + self.num_requests = c.NUM_REQUESTS + self.excel = c.EXCEL_INPUT_LOCATION + self.used_profiles = [] + self.num_sent = 0 + self.df = pd.read_excel(self.excel) + + def run(self): + """ + Executes the LinkedIn bot, performing login, reading URLs, + and sending connection requests. + + Returns: A success message, or any error messages the program encounters. + """ + try: + # Create LinkedIn Bot + bot = LinkedinBot() + # Login + if not bot.login(): + return "Could not login. Possible Captcha." + + # Send request + i = 0 + error = "" + while self.num_sent < self.num_requests: + # Go to profile + url = self.df['Link'][i] + bot.driver.get(url) + self.used_profiles.append(url) + time.sleep(random.uniform(3,5)) + # Assess connection conditions and send + try: + if bot.extract_connection_count() >= self.min_connections: + if bot.has_connect_button(): + message = bot.write_message() + if bot.send_connection_request(message): + self.num_sent+=1 + elif bot.has_hidden_connect_button(): + message = bot.write_message() + if bot.more_then_connect(message): + self.num_sent+=1 + elif bot.has_accept_button(): + bot.accept_request() + + # Check if limit has been triggered + if bot.check_weekly_limit(): + error = "Weekly limit reached. " + self.num_sent + " sent." + break + except: + pass + i+=1 + if i < self.num_requests: + time.sleep(random.uniform(3,5)) + + bot.driver.quit() + + # Remove used profiles from dataframe + self.df = self.df[~self.df['Link'].isin(self.used_profiles)] + + # Save to excel + self.df.to_excel(self.excel, index=False) + + if error != "": + return error + return "Completed: " + str(self.num_sent) + " sent." + + except WebDriverException: + self.df = self.df[~self.df['Link'].isin(self.used_profiles)] + self.df.to_excel(self.excel, index=False) + return "WebDriverException Error" + except KeyboardInterrupt: + self.df = self.df[~self.df['Link'].isin(self.used_profiles)] + self.df.to_excel(self.excel, index=False) + return "Program stopped by the user." + except: + self.df = self.df[~self.df['Link'].isin(self.used_profiles)] + self.df.to_excel(self.excel, index=False) + return "Unexpected error." \ No newline at end of file diff --git a/connection_automator/data/saved_connect_inputs.pkl b/connection_automator/data/saved_connect_inputs.pkl new file mode 100644 index 0000000..e69de29 diff --git a/connection_automator/linkedin_bot.py b/connection_automator/linkedin_bot.py new file mode 100644 index 0000000..00408ff --- /dev/null +++ b/connection_automator/linkedin_bot.py @@ -0,0 +1,300 @@ +""" +The LinkedIn Bot module of the LinkedIn Connection Automation Bot. + +This module handles the setup and interaction of the Selenium +Webdriver for the bot. It includes the class LinkedinBot, which +includes methods which interact with data from the website. +This module works in conjunction with the Bot Controller module +to use Selenium to automatically connect with LinkedIn Profiles. + +Author: Ethan Baker +""" +import time +import random +from selenium import webdriver +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.chrome.options import Options +from selenium.common.exceptions import NoSuchElementException +from selenium.common.exceptions import WebDriverException +import consts as c + +class LinkedinBot(): + """ + Encapsulates the functionality related to interacting with + LinkedIn using Selenium. + """ + + def __init__(self): + """ + Initializes the Linkedin Bot. + """ + self.user = c.USERNAME + self.password = c.PASSWORD + self.num_requests = c.NUM_REQUESTS + self.min_connections = c.MINIMUM_CONNECTION_COUNT + self.excel = c.EXCEL_INPUT_LOCATION + self.message = c.MESSAGE + options = Options() + user_agent = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" + " AppleWebKit/537.36 (KHTML, like Gecko)" + " Chrome/114.0.5735.133 Safari/537.36") + + options.add_argument(f'user-agent={user_agent}') + self.driver = webdriver.Chrome(options=options) + + def login(self): + """ + Logs into LinkedIn using the provided username and password. + + Returns: True if the login is successful, False otherwise. + """ + # Enter username and password into fields + self.driver.get("https://linkedin.com/login") + username_field = self.driver.find_element("id", "username") + username_field.send_keys(self.user) + password_field = self.driver.find_element("id", "password") + password_field.send_keys(self.password) + + # Randomize wait to avoid being flagged + time.sleep(random.uniform(3,5)) + + # Submit + password_field.send_keys(Keys.ENTER) + + # Check success + time.sleep(random.uniform(3,5)) + if self.driver.current_url == "https://www.linkedin.com/feed/": + return True + else: + time.sleep(30) + return False + + def send_connection_request(self, message): + """ + Sends a connection request to a LinkedIn profile. + + Returns: True if connection request was successful, False otherwise. + + Parameter message: The connection message being sent. + Precondition: message must be a String where len(message) <= 300. + """ + try: + connect_button = self.driver.find_element("xpath", + ( "(//button[contains(@aria-label," + " 'Invite') and contains(@class, " + "'pvs-profile-actions__action')])")) + connect_button.click() + time.sleep(random.uniform(3,5)) + + add_note_button = self.driver.find_element("xpath", + "//button[@aria-label='Add a note']") + add_note_button.click() + time.sleep(random.uniform(3,5)) + + message_entry = self.driver.find_element("xpath", + "//textarea[@name='message']") + message_entry.send_keys(message) + time.sleep(random.uniform(3,5)) + + send_button = self.driver.find_element("xpath", + "//button[@aria-label='Send now']") + send_button.click() + time.sleep(random.uniform(3,5)) + + self.driver.find_element('xpath', + ("//button[contains(@aria-label, 'Pending') and contains(@class," + " 'pvs-profile-actions__action') and .//span[contains(@class, " + "'artdeco-button__text') and text()='Pending']]")) + return True + + except NoSuchElementException: + return False + + def more_then_connect(self, message): + """ + Sends a conenction request to a LinkedIn profile when the blue + connection button isn't visible. + + Returns: True if the connection request was successful, False otherwise. + + Parameter message: The connection message being sent. + Precondition: message must be a String where len(message) <= 300. + """ + try: + more_button = self.driver.find_element("xpath", + ("(//button[contains(@class, 'artdeco-dropdown__trigger') " + "and contains(@class, 'artdeco-dropdown__trigger--placement-" + "bottom') and contains(@class, 'ember-view') and contains(@class," + " 'pvs-profile-actions__action') and contains(@class, " + "'artdeco-button') and contains(@class, " + "'artdeco-button--secondary') and contains(@class, " + "'artdeco-button--muted') and contains(@class," + " 'artdeco-button--2') and @aria-label='More actions'])[2]")) + more_button.click() + self.driver.execute_script("window.scrollBy(0, 300)") + time.sleep(random.uniform(3,5)) + + connect_button = self.driver.find_element("xpath", + ("(//div[contains(@aria-label, 'Invite') and " + "contains(@aria-label, 'to connect')])")) + self.driver.execute_script("arguments[0].click();", connect_button) + time.sleep(random.uniform(3,5)) + + add_note_button = self.driver.find_element("xpath", + "//button[@aria-label='Add a note']") + add_note_button.click() + time.sleep(random.uniform(3,5)) + + message_entry = self.driver.find_element("xpath", + "//textarea[@name='message']") + message_entry.send_keys(message) + time.sleep(random.uniform(3,5)) + + send_button = self.driver.find_element("xpath", + "//button[@aria-label='Send now']") + send_button.click() + time.sleep(random.uniform(3,5)) + + self.driver.find_element('xpath', + ("//div[contains(@aria-label, 'Pending') and " + "contains(@class, 'artdeco-dropdown__item')]")) + return True + + except WebDriverException: + return False + + def accept_request(self): + """ + Accepts any incoming connection requests from users being checked. + + Returns: True if the connection request was successful, False otherwise. + """ + try: + accept_button = self.driver.find_element("xpath", + ("//button[contains(@aria-label, 'Accept') and " + "contains(@class, 'pvs-profile-actions__action')]")) + accept_button.click() + time.sleep(random.uniform(3,5)) + + self.driver.find_element("xpath", + ("//button[contains(@aria-label, 'Message') and " + "contains(@class, 'artdeco-button--primary')]")) + return True + + except NoSuchElementException: + return False + + def extract_connection_count(self): + """ + Extracts the connection count from the LinkedIn Profile page. + + If the connection count isn't visable, it returns the follower count instead. + + Returns: An Integer between 0 and 500 representing the + number of connections that the profile has. + """ + time.sleep(random.uniform(3,5)) + try: + connection_count = self.driver.find_element("xpath", + '(//span[@class="t-bold"])[1]') + number = connection_count.text.replace(",", "") + if number.isnumeric(): + return int(number) + elif connection_count.text == "500+": + return 500 + else: + return 0 + except NoSuchElementException: + return 0 + + def has_connect_button(self): + """ + Checks if LinkedIn gives the user the option to connect with the current profile. + + Returns: True if the profile has the conenct button, False otherwise. + """ + try: + connect = self.driver.find_element("xpath", + "(//span[@class='artdeco-button__text'])[6]") + connect_following = self.driver.find_element("xpath", + "(//span[@class='artdeco-button__text'])[8]") + if connect.text == "Connect" or connect_following.text=="Connect": + return True + else: + return False + except NoSuchElementException: + return False + + def has_accept_button(self): + """ + Checks if the user can accept an invite from the current profile. + + Returns True if the profile has an accept button, False otherwise. + """ + try: + accept = self.driver.find_element("xpath", + "(//span[@class='artdeco-button__text'])[6]") + if accept.text == "Accept": + return True + else: + return False + except NoSuchElementException: + return False + + def has_hidden_connect_button(self): + """ + Checks if the user can send a request after clicking more. + + Returns True if the profile has a hidden connect button, False otherwise. + """ + try: + self.driver.find_element("xpath", + ("(//div[contains(@class, 'artdeco-dropdown__item') and " + "contains(@class, 'artdeco-dropdown__item--is-dropdown')" + " and contains(@class, 'ember-view') and contains(@class," + " 'full-width') and contains(@class, 'display-flex') and" + " contains(@class, 'align-items-center')]/" + "span[text()='Connect'])[1]")) + return True + except NoSuchElementException: + return False + + def write_message(self): + """ + Extracts the first name from the LinkedIn profile page, + then assembles the personalized message to send. Allows the + user the put [FULL NAME] and [FIRST NAME] in their message to instruct the + program to automatically insert the profile's name. + + Returns: A String representing the personalized connection message. + """ + full_name = self.driver.find_element("xpath", + ('(//h1[@class="text-heading-xlarge inline t-24' + ' v-align-middle break-words"])[1]')).text + name_list = full_name.split(" ") + first = name_list[0] + if first.lower() not in ['dr.', 'mr.' 'mrs.', + 'ms.', 'dr', 'mr', 'mrs' 'ms']: + first_name = name_list[0] + else: + first_name = name_list[1] + + msg = c.MESSAGE.replace('[FULL NAME]', full_name) + msg =msg.replace('[FIRST NAME]', first_name) + return msg + + def check_weekly_limit(self): + """ + Checks if the weekly request limit has been reached. + + Returns: True if the request limit has been reached, False otherwise. + """ + try: + self.driver.find_element("xpath", + ("//h2[@class='ip-fuse-limit-alert__header t-20 t-black ph4' " + "and @id='ip-fuse-limit-alert__header' and text()='You’ve " + "reached the weekly invitation limit']")) + return True + except NoSuchElementException: + return False + \ No newline at end of file diff --git a/connexion_logo.png b/connexion_logo.png new file mode 100644 index 0000000..799a8d0 Binary files /dev/null and b/connexion_logo.png differ diff --git a/consts.py b/consts.py new file mode 100644 index 0000000..7ef1d73 --- /dev/null +++ b/consts.py @@ -0,0 +1,33 @@ +""" +Constants for ConneXion. + +Author: Ethan Baker +""" +# Interface +PATH_TO_LOGO = "data/connexion_logo.png" + +# Search Tool +POSITIONS = [] +LOCATIONS = [] +EXPERIENCE_OPERATOR = "=" +EXPERIENCE_YEARS = 10 + +REPEAT_QUERIES = False +API_KEY = "" +EXCEL_FILE_LOCATION = "" + +API_KEY_EXHAUSTED = "AIzaSyD0OT1hnGcITg_39SUtJMvz3cSeP-FdrkQ" +SEARCH_ENGINE_ID = "137c20153778c4c31" +PROFILE_INDEX_LOCATION = "search_tool/data/indexed_profiles.pkl" +QUERY_INDEX_LOCATION = "search_tool/data/indexed_queries.pkl" + +# Connection Request Tool +USERNAME = "" +PASSWORD = "" + +MESSAGE = "" +NUM_REQUESTS = "" +MINIMUM_CONNECTION_COUNT = "" + +EXCEL_INPUT_LOCATION = "" + diff --git a/data/connexion_logo.png b/data/connexion_logo.png new file mode 100644 index 0000000..799a8d0 Binary files /dev/null and b/data/connexion_logo.png differ diff --git a/interface.py b/interface.py new file mode 100644 index 0000000..90b01d2 --- /dev/null +++ b/interface.py @@ -0,0 +1,555 @@ +""" +The primary GUI interface for the ConneXion tool. + +Includes a home screen with two buttons to search for and conenct +with LinkedIn profiles. The buttons create new windows that include +user settings and filters to be used in the program. + +Author: Ethan Baker +""" +import os +import tkinter as tk +from PIL import ImageTk, Image +from tkinter import ttk +import pickle +import consts as c +import search_tool.search_for_profiles as search_for_profiles +from connection_automator.bot_controller import BotController + +class Interface: + """ + A controller for the overall ConneXion Application. + + Manages all of the buttons and entry fields for the application. + Contains methods to create GUI windows and to initiate the + execution of the search and connect features. + """ + + def __init__(self): + """ + Initializes the home window of the ConneXion app. + """ + self.root = tk.Tk() + self.root.title("ConneXion for LinkedIn") + self.root.geometry("350x250") # Set initial window size + self.root.resizable(False, False) # Disable window resizing + + blank = tk.Label(self.root) + blank.pack() + + # Insert image for logo + logo = Image.open(c.PATH_TO_LOGO) + logo = logo.resize((250, 110), Image.ANTIALIAS) + self.tk_logo = ImageTk.PhotoImage(logo) + image_label = tk.Label(self.root, image=self.tk_logo) + image_label.pack_propagate(0) # Disable label resizing + image_label.pack() + + self.search_button = tk.Button(self.root, text="Search for Profiles", + command=self.open_search_page, width=20) + self.search_button.pack(pady=10) + + self.connect_button = tk.Button(self.root, text="Automate Connections", + command=self.open_connect_page, + width=20) + self.connect_button.pack(pady=0) + + self.search_message = "" + self.connect_message = "" + + + def open_search_page(self): + """ + Creates a new GUI window that includes entry boxes for filters + and user preferences for the profile search feature. Also includes + a search button which initiates the backend of the search program. + """ + try: + with open ("search_tool/data/saved_search_inputs.pkl", 'rb') as file: + saved_settings = pickle.load(file) + except: + pass + + # Function to open the Profile Search page + search_page = tk.Toplevel(self.root) + search_page.title("Search for Profiles") + search_page.geometry("300x510") # Set window size + search_page.resizable(False, False) # Disable window resizing + + # Create Filters frame + filters_frame = tk.LabelFrame(search_page, text="Filters") + filters_frame.pack(pady=10) + + # Create location entry field + location_label = tk.Label(filters_frame, + text="Locations (Comma Separated):") + location_label.pack(pady=5) + location_entry = tk.Entry(filters_frame) + location_entry.pack() + try: + location_entry.insert(0, saved_settings[0]) + except: + pass + + # Create position entry field + position_label = tk.Label(filters_frame, + text="Positions (Comma Separated):") + position_label.pack(pady=5) + position_entry = tk.Entry(filters_frame) + position_entry.pack() + try: + position_entry.insert(0, saved_settings[1]) + except: + pass + + # Create years of experience and operator entry fields + exp_label = tk.Label(filters_frame, text="Years of Experience:") + exp_label.pack(pady=5) + + exp_frame = tk.Frame(filters_frame) + exp_frame.pack(pady=5) + + operator_var = tk.StringVar() + operator_dropdown = ttk.OptionMenu(exp_frame, + operator_var, '', '>', '<', "=") + operator_dropdown.pack(side=tk.LEFT, padx=5) + operator_var.set(saved_settings[2]) + + experience_entry = tk.Entry(exp_frame, width=13) + experience_entry.pack(side=tk.LEFT, padx=5) + try: + experience_entry.insert(0, saved_settings[3]) + except: + pass + + # Create Advanced Configuration frame + adv_config_frame = tk.LabelFrame(search_page, text="Advanced") + adv_config_frame.pack(pady=10) + + # Create output location field + output_label = tk.Label(adv_config_frame, text="Output Location:") + output_label.pack(pady=5) + output_entry = tk.Entry(adv_config_frame) + output_entry.pack() + try: + output_entry.insert(0, saved_settings[4]) + except: + pass + + # Create API key entry field + api_label = tk.Label(adv_config_frame, text="Custom Search API Key:") + api_label.pack(pady=5) + api_entry = tk.Entry(adv_config_frame) + api_entry.pack() + try: + api_entry.insert(0, saved_settings[5]) + except: + pass + + # Create Repeat Queries checkbox + repeat_var = tk.IntVar() + repeat_check = tk.Checkbutton(adv_config_frame, + text="Repeat Queries", + variable=repeat_var) + repeat_check.pack(pady=5) + try: + repeat_var.set(saved_settings[6]) + except: + pass + + # Create message label + self.search_msg_label = tk.Label(search_page, text="",) + self.search_msg_label.pack(pady=5) + + # Create Search button + search_button = tk.Button(search_page, text="Search", width=20, + command=lambda: self.from_search_gui(position_entry.get(), + location_entry.get(), + operator_var.get(), + experience_entry.get(), + output_entry.get(), + api_entry.get(), + repeat_var.get())) + search_button.pack(pady=10) + + def open_connect_page(self): + """ + Creates a new GUI window that includes entry boxes for login information + and user preferences for the connection request feature. Also includes + a connect button which initiates the backend of the connect program. + """ + try: + saved_inputs = "connection_automator/data/saved_connect_inputs.pkl" + with open (saved_inputs, 'rb') as file: + saved_settings = pickle.load(file) + except: + pass + # Function to open the Connection Automator page + connect_page = tk.Toplevel(self.root) + connect_page.title("Automate Connections") + connect_page.geometry("300x610") # Set window size + connect_page.resizable(False, False) + + # Set LinkedIn frame + linkedin_frame = tk.LabelFrame(connect_page, text="LinkedIn") + linkedin_frame.pack(pady=10) + + # LinkedIn username and password labels and entry boxes + user_label = tk.Label(linkedin_frame, text="LinkedIn Username:") + user_label.pack(pady=5) + user_entry = tk.Entry(linkedin_frame) + user_entry.pack(padx=6) + try: + user_entry.insert(0, saved_settings[0]) + except: + pass + + password_label = tk.Label(linkedin_frame, text="LinkedIn Password:") + password_label.pack(pady=5) + password_entry = tk.Entry(linkedin_frame, show="*") + password_entry.pack(padx=6, pady=5) + + # Set filters frame + filters_frame = tk.LabelFrame(connect_page, text="Filters") + filters_frame.pack(pady=10) + + # Connection message label and entry box + con_msg_label = tk.Label(filters_frame, text="Connection Message:") + con_msg_label.pack(pady=5) + con_msg_entry= tk.Entry(filters_frame) + con_msg_entry.pack(pady=5) + try: + con_msg_entry.insert(0, saved_settings[1]) + except: + pass + + # Number of requests to send label entry box + num_req_label = tk.Label(filters_frame, + text="Number of requests to send:") + num_req_label.pack(pady=5) + num_req_entry = tk.Entry(filters_frame) + num_req_entry.pack() + try: + num_req_entry.insert(0, saved_settings[2]) + except: + pass + + # Minimum connection count label and entry box + min_label = tk.Label(filters_frame, + text="Minimum connections count:") + min_label.pack(pady=5) + min_entry = tk.Entry(filters_frame) + min_entry.pack(padx=6, pady=5) + try: + min_entry.insert(0, saved_settings[3]) + except: + pass + + # Set advanced frame + adv_frame = tk.LabelFrame(connect_page, text="Advanced") + adv_frame.pack(pady=10) + + # Excel file path label and entry box + exl_label = tk.Label(adv_frame, text="Excel file path:") + exl_label.pack(pady=5) + exl_entry = tk.Entry(adv_frame) + exl_entry.pack(pady=5) + try: + exl_entry.insert(0, saved_settings[4]) + except: + pass + + # Set message label + self.connect_msg_label = tk.Label(connect_page, text="") + self.connect_msg_label.pack(pady=5) + + # Set Connect button + search_button = tk.Button(connect_page, text="Connect", width=20, + command=lambda: self.from_connect_gui(user_entry.get(), + password_entry.get(), + con_msg_entry.get(), + num_req_entry.get(), + min_entry.get(), + exl_entry.get())) + search_button.pack(pady=10) + + + def display_search_message(self): + """ + Changes the search message label to display a message to the user. + + The text of the message depends on what the value of self.search_message + is when it is called. It is used to display error messages and let + the user know if the search methods were successful. + """ + self.search_msg_label.config(text=self.search_message, + wraplength=300, height=1) + self.search_msg_label.update() + + def display_connect_message(self): + """ + Changes the connect message label to display a message to the user. + + The text of the message depends on what the value of self.connect_message + is when it is called. It is used to display error messages and let + the user know if the connect methods were successful. + """ + self.connect_msg_label.config(text=self.connect_message, + wraplength=300, height=1) + self.connect_msg_label.update() + + + def from_search_gui(self, positions, locations, + exp_op, exp_num, output, api_key, repeat): + """ + Converts the users' GUI input for the profile search tool to create + input constants which are used throughout the program to customize results. + Uses save_search_settings to save the user's input for the next time. + + Parameter positions: The positions to search for. + Precondition: positions is a String. + + Parameter locations: The locations to search for. + Precondition: locations is a String. + + Parameter exp_op: The operator for years of experience. + Precondition: exp_op is ">", "<", or "=". + + Parameter exp_num: The number for years of experience. + Precondition: exp_num is a String. + + Parameter output: The location of the output excel file. + Precondition: output is a String. + + Parameter api_key: The Custom Search API key. + Precondition: api_key is a String. + + Parameter repeat: If the user would like to repeat queries or not. + Precondition: repeat is 0 or 1. + """ + self.save_search_settings(positions, locations, + exp_op, exp_num, output, api_key, repeat) + search = True + try: + if locations !="": + c.LOCATIONS = locations.split(', ') + else: + search = False + self.search_message = "Locations must not be blank." + self.display_search_message() + raise ValueError + + if positions != "": + c.POSITIONS = positions.split(', ') + else: + search = False + self.search_message = "Positions must not be blank." + self.display_search_message() + raise ValueError + + if exp_op in [">", "<", "="]: + c.EXPERIENCE_OPERATOR = exp_op + else: + search = False + self.search_message = "YoE dropdown must not be blank." + self.display_search_message() + raise ValueError + + if exp_num.isnumeric() and 0 <= int(exp_num) <= 30: + c.EXPERIENCE_YEARS = exp_num + else: + search = False + self.search_message = "YoE must be between 0 and 30." + self.display_search_message() + raise ValueError + + isdir = os.path.isdir(os.path.dirname(output)) + access = os.access(os.path.dirname(output), os.W_OK) + end = output[-4:] == 'xlsx' + if isdir and access and end: + c.EXCEL_FILE_LOCATION = output + else: + search = False + self.search_message = "Invalid output location." + self.display_search_message() + raise ValueError + + if api_key != "": + c.API_KEY = api_key + else: + search = False + self.search_message = "API Key must not be blank." + self.display_search_message() + raise ValueError + + if repeat==0: c.REPEAT_QUERIES = False + else: c.REPEAT_QUERIES = True + + if search: + self.search_message="Searching..." + self.display_search_message() + self.search_message = search_for_profiles.execute_search() + self.display_search_message() + + except ValueError: + pass + + def from_connect_gui(self, user, password, msg, num, min, excel_path): + """ + Converts the users' GUI input for the connection request tool to create + input constants which are used throughout the program to customize results. + Uses save_connect_settings to save the user's input for the next time. + + Parameter user: The user's LinkedIn username. + Precondition: user is a String. + + Parameter password: The user's LinkedIn password. + Precondition: password is a String. + + Parameter msg: The custom message the user wants to send with the request. + Precondition: msg is a String. + + Parameter num: The number of connection requests to send + from the user's account. + Precondition: num is a String. + + Parameter min: The minimum number of connections a profile + must have to connect. + Precondition: min is a String. + + Parameter excel_path: The filepath of the excel file that + contains urls of profiles. + Precondition: excel_path is a String. + """ + self.save_connect_settings(user, msg, num, min, excel_path) + connect = True + try: + if user !="": + c.USERNAME = user + else: + connect = False + self.connect_message = "Username must not be blank." + self.display_connect_message() + raise ValueError + + if password != "": + c.PASSWORD = password + else: + connect = False + self.connect_message = "Password must not be blank." + self.display_connect_message() + raise ValueError + + if len(msg) <= 300: + c.MESSAGE = msg + else: + connect = False + self.connect_message = "Message must be less than 300 chars." + self.display_connect_message() + raise ValueError + + if num.isnumeric() and 1 <= int(num) <= 50: + c.NUM_REQUESTS = int(num) + else: + connect = False + self.connect_message = "Num requests must be between 1 and 50." + self.display_connect_message() + raise ValueError + + if min.isnumeric() and 0<= int(min) <= 500: + c.MINIMUM_CONNECTION_COUNT = int(min) + else: + connect = False + self.connect_message = "Min connections must be between 0 and 500." + self.display_connect_message() + raise ValueError + + end = excel_path[-4:] == 'xlsx' + if os.path.exists(excel_path) and end: + c.EXCEL_INPUT_LOCATION = excel_path + else: + connect = False + self.connect_message = "Invalid Excel file location." + self.display_connect_message() + raise ValueError + + if connect: + self.connect_message = "Connecting: Do not close window!" + self.display_connect_message() + controller = BotController() + self.connect_message = controller.run() + self.display_connect_message() + + except ValueError: + pass + + def save_search_settings(self, positions, locations, exp_op, + exp_num, output, api_key, repeat): + """ + Saves user input settings on Search button press. + + Converts input into a list and saves it as a .pkl file. The .pkl + file is used upon initialization of the Search for Profiles window + to set the user's last used settings. + + Parameter positions: The positions to search for. + Precondition: positions is a String. + + Parameter locations: The locations to search for. + Precondition: locations is a String. + + Parameter exp_op: The operator for years of experience. + Precondition: exp_op is ">", "<", or "=". + + Parameter exp_num: The number for years of experience. + Precondition: exp_num is a String. + + Parameter output: The location of the output excel file. + Precondition: output is a String. + + Parameter api_key: The Custom Search API key. + Precondition: api_key is a String. + + Parameter repeat: If the user would like to repeat queries or not. + Precondition: repeat is 0 or 1. + """ + settings = [locations, positions, exp_op, exp_num, output, api_key, repeat] + with open ("search_tool/data/saved_search_inputs.pkl", 'wb') as file: + pickle.dump(settings, file) + + def save_connect_settings(self, user, msg, num, min, excel_path): + """ + Saves user input settings on Connect button press. + + Converts input into a list and saves it as a .pkl file. The .pkl file + is used upon the initialization of the Connection Automator window + to set the use's last used settings + + Parameter user: The user's LinkedIn username. + Precondition: user is a String. + + Parameter msg: The custom message the user wants to send with the request. + Precondition: msg is a String. + + Parameter num: The number of connection requests to + send from the user's account. + Precondition: num is a String. + + Parameter min: The minimum number of connections a + profile must have to connect. + Precondition: min is a String. + + Parameter excel_path: The filepath of the excel file + that contains urls of profiles. + Precondition: excel_path is a String. + """ + settings = [user,msg,num,min,excel_path] + with open ("connection_automator/data/saved_connect_inputs.pkl", + 'wb') as file: + pickle.dump(settings, file) + + def run(self): + """ + Launches the GUI application. + """ + self.root.mainloop() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..27e138e --- /dev/null +++ b/main.py @@ -0,0 +1,11 @@ +""" +The primary application script for ConneXion. + +Author: Ethan Baker +""" +from interface import Interface + +# Application code +if __name__ == '__main__': + interface = Interface() + interface.run() \ No newline at end of file diff --git a/search_tool/data/indexed_profiles.pkl b/search_tool/data/indexed_profiles.pkl new file mode 100644 index 0000000..e69de29 diff --git a/search_tool/data/indexed_queries.pkl b/search_tool/data/indexed_queries.pkl new file mode 100644 index 0000000..e69de29 diff --git a/search_tool/data/saved_search_inputs.pkl b/search_tool/data/saved_search_inputs.pkl new file mode 100644 index 0000000..e69de29 diff --git a/search_tool/google_api.py b/search_tool/google_api.py new file mode 100644 index 0000000..fffbe11 --- /dev/null +++ b/search_tool/google_api.py @@ -0,0 +1,178 @@ +""" +The Google Search API module of the LinkedIn Search Tool. + +This module allows the program to interact with Google's Custom Search +API and facilitates the conversion of user filter preferences into +search queries. + +Author: Ethan Baker +""" +import random +import requests +import pandas as pd +import consts as c +from search_tool.indexed_data import IndexedData + +class GoogleSearchAPI: + """ + A class representing a custom Google search engine. + + The class contains properties key and engine_id, which represent + the API key, and custom search engine ID, respectively. The class contains + methods to generate search queries and to return search results of a + query. + """ + + def __init__(self, key, engine_id): + """ + Creates a new GoogleSearchAPI object. + + Parameter key: The API key used in the Google Search. + Precondition: key is a String object representing a valid API key. + + Parameter engine_id: The ID of the Custom Google search engine. + Precondition: engine_id is a String object representing a + valid search engine engine ID. + """ + self.key = key + self.id = engine_id + + def generate_queries(self, preferences): + """ + Generates a list of search terms to input into Google. + + These queries are based on the user's preferences as per + consts.py. Used to input into Google, as each search term is + limited to 100 results per Google's API. Generating a list of + search terms instead of using Google's AND operator allows for + more clients to be found as each term generates 100 new results. + + Returns: A list of strings that represent search queries. + + Parameter preferences: A dictionary of user search preferences. + Precondition: preferences is generated by + search_for_profiles.load_preferences() and is based on a consts.py + configuration which follows the rules outlined in that file. + """ + queries = [] + # Generate list of queries + for pos in preferences["position"]: + for loc in preferences["location"]: + if preferences["exp_op"] == ">": + for years in list(range(int(preferences["exp_num"]), 31)): + s = "" + if years != 1: s = "s" + queries.append('site:linkedin.com/in intitle:("'+pos+ + '") AND ("'+loc+'") AND ("'+ + str(years)+' year' + s + '")') + elif preferences["exp_op"] == "<": + for years in list(range(0, int(preferences["exp_num"]))): + s = "" + if years != 1: s = "s" + if years == 0: + queries.append('site:linkedin.com/in intitle:("'+pos+ + '") AND ("'+loc + +'") -"year" -"years"') + else: + queries.append('site:linkedin.com/in intitle:("'+pos+ + '") AND ("'+loc+'") AND ("'+ + str(years)+' year' + s + '")') + elif preferences["exp_op"] == "=": + s = "" + if preferences["exp_num"] != 1: s = "s" + queries.append('site:linkedin.com/in intitle:("'+pos+ + '") AND ("'+loc+'") AND ("'+ + str(preferences["exp_num"])+' year")') + + # Remove previously searched queries if necessary + if c.REPEAT_QUERIES == False: + indexed = IndexedData(c.PROFILE_INDEX_LOCATION, c.QUERY_INDEX_LOCATION) + temp = queries.copy() + for query in temp: + if indexed.check_dup_query(query): + queries.remove(query) + return queries + + def search(self, terms): + """ + Searches google for terms as per user preferences in consts.py. + + Uses the list of terms from generate urls to search google until + the daily free API request limit is reached. Records client profiles + in a pandas DataFrame that includes the page Title, Url, and Snippets. + After using a search query from terms, it records it so it doesnt get + used again later. As it compiles the list of clients, the method checks + for and removes any previously indexed profiles from the DataFrame. The + method stops when it encounters an error from the API, either returning + the error code, or in the case of the API request limit filling up, it + returns a message signaling that. + + Returns: A list of length 2 where the first element is a pandas DataFrame + that contains new profile information, and the second element is an error + message, if one occured. + + Parameter terms: The list of terms used to search Google. + Precondition: terms is made up of Strings that represent Google + search terms. + """ + # Set up DataFrame + df = pd.DataFrame() + titles = [] + links = [] + snippets = [] + + er_msg = "" + + indexed = IndexedData(c.PROFILE_INDEX_LOCATION, c.QUERY_INDEX_LOCATION) + code = "" + brk = False + random.shuffle(terms) + for term in terms: + num_results = 100 + start = 1 + while num_results > start: + url = f"https://www.googleapis.com/customsearch/v1?key=" + url = url + f"{self.key}&cx={self.id}&q={term}&start={start}" + data = requests.get(url).json() + + # Look for error + if data.get("error") is not None: + brk = True + error = data.get("error") + code = error.get("code") + break + + # Get search result data + search_info = data.get("searchInformation") + if search_info is not None: + total_results = search_info.get("totalResults") + if total_results is not None: + num_results = min(100, int(total_results)) + start += 10 + + if data.get("items") is not None: + for result in data.get("items"): + if not indexed.check_dup_profile(result.get("link")): + indexed.add_indexed_profile(result.get("link")) + titles.append(result.get("title")) + links.append(result.get("link")) + snippets.append(result.get("snippet")) + # Index search query + indexed.add_indexed_query(term) + if brk: + if code != 429: + er_msg = "Google API Error " + str(code) + else: + er_msg = "API request limit reached." + break + + indexed.save_indexed_queries() + indexed.save_indexed_profiles() + + # Create and return data frame + df["Title"] = titles + df["Link"] = links + df["Snippets"] = snippets + + lst = [df, er_msg] + return lst diff --git a/search_tool/indexed_data.py b/search_tool/indexed_data.py new file mode 100644 index 0000000..92d3073 --- /dev/null +++ b/search_tool/indexed_data.py @@ -0,0 +1,169 @@ +""" +The Indexed Data module of the LinkedIn Search Tool. + +This module allows the program to retrieve and record information +about profiles that have been indexed and query terms that have been +searched in the past. This ensures that the application does not return +repreat profiles or unnecessarily rely on search terms that have +recently been used. + +Author: Ethan Baker +""" +import pickle + +class IndexedData(): + """ + A class representing data that has previously been recorded by the application. + + Contains properties profiles and queries, which represent alphabetically sorted + lists of previously indexed profiles and previously used search queries. Also + includes properties profile_file and query_file, which are the String filepaths + to the indexes This class contains methods to load, check for duplicates, + add to, and save both of the lists. + """ + + def __init__(self, profile_file, query_file): + """ + Creates an Indexed Data Object. + + This object contains two alphabetically sorted lists of strings + that represent profiles (represented by self.profiles) and queries + (represented by self.queries) which were indexed after running the + program a previous time. + + Parameter profile_file: the filepath to the profile index. + Precondition: profile_file is a String containing a valid .pkl filepath. + + Parameter query_file: the filepath to the query index. + Precondition: query_file is a String containing a valid .pkl filepath. + """ + self.profile_file = profile_file + self.query_file = query_file + self.profiles = self.load_indexed_profiles() + self.queries = self.load_indexed_queries() + + def load_indexed_profiles(self): + """ + Loads the list of alphabetically sorted accounts from self.profile_file. + """ + try: + with open(self.profile_file, 'rb') as file: + return pickle.load(file) + except: + return [] + + def check_dup_profile(self, profile): + """ + Checks for duplicate profiles using a recursive binary search algorithm. + + Returns: True if self.profiles contains profile, otherwise False. + + Parameter profile: the profile of the user that is being searched for. + Precondition: profile is a String that represents a url. + """ + left = 0 + right = len(self.profiles) - 1 + + while left <= right: + mid = (left + right) // 2 + value = self.profiles[mid] + + if value == profile: return True + elif value < profile: left = mid + 1 + else: right = mid - 1 + + return False + + def add_indexed_profile(self, profile): + """ + Adds a new profile url to its correct location in the sorted list. + + Modifies self.profiles to add the url of the new profile + to its correct position based on alphabetical order. + + Parameter profile: The profile being added. + Precondition: profile is a String that is not already in the list. + """ + left = 0 + right = len(self.profiles) - 1 + + while left <= right: + mid = (left + right) // 2 + value = self.profiles[mid] + + if value < profile: + left = mid + 1 + else: + right = mid - 1 + + self.profiles.insert(left, profile) + + def save_indexed_profiles(self): + """ + Saves the updated list of indexed accounts to self.profile_file. + """ + with open(self.profile_file, 'wb') as file: + pickle.dump(self.profiles, file) + + def load_indexed_queries(self): + """ + Loads the list of alphabetically sorted queries from self.query_file. + """ + try: + with open(self.query_file, 'rb') as file: + return pickle.load(file) + except: + return [] + + def check_dup_query(self, query): + """ + Checks for duplicate queries using a recursive binary search algorithm. + + Returns: True if self.queries contains query, otherwise False. + + Parameter query: A query search term. + Precondition: query is a String that represents a valid query. + """ + left = 0 + right = len(self.queries) -1 + + while left <= right: + mid = (left + right) // 2 + value = self.queries[mid] + + if value == query: return True + elif value < query: left = mid + 1 + else: right = mid - 1 + + return False + + def add_indexed_query(self, query): + """ + Adds a new query to its correct location in the sorted list. + + Modifies self.queries to add the new query search term + to its correct position based on alphabetical order. + + Parameter query: A query search term. + Precondition: query is a String that is not already in the list. + """ + left = 0 + right = len(self.queries) - 1 + + while left <= right: + mid = (left + right) // 2 + value = self.queries[mid] + + if value < query: + left = mid + 1 + else: + right = mid - 1 + + self.queries.insert(left, query) + + def save_indexed_queries(self): + """ + Saves the updated list of indexed queries to self.query_file. + """ + with open(self.query_file, 'wb') as file: + pickle.dump(self.queries, file) \ No newline at end of file diff --git a/search_tool/search_for_profiles.py b/search_tool/search_for_profiles.py new file mode 100644 index 0000000..914ac7e --- /dev/null +++ b/search_tool/search_for_profiles.py @@ -0,0 +1,96 @@ +""" +The primary module for the LinkedIn Search Tool section of ConneXion. + +This module faciliates the overall execution of the search methods, which +includes loading the preferences from consts.py, searching Google for +LinkedIn profiles based on those preferences, and saving the search results +to an Excel file, while indexing the data to a .pkl file. +""" +import pandas as pd +from search_tool.google_api import GoogleSearchAPI +import consts as c + +def load_preferences(): + """ + Loads the search preferences from the consts.py file. + + Returns: A dictionary object with four keys named "location", + "position", "exp_op", and "exp_num" which each correspond to + the user's preference for that search filter category. + """ + dict = {"location" : c.LOCATIONS, "position" : c.POSITIONS, + "exp_op" : c.EXPERIENCE_OPERATOR, "exp_num" : c.EXPERIENCE_YEARS} + return dict + +def run_search(preferences): + """ + Uses Google's search API to search LinkedIn accounts based on preferences. + + Uses preferences to generate a specific search term based on position, + location, and experience, then uses the Google API to search for the term. + + Returns: A list of length 2 where the fist element is a pandas DataFrame + containing search results, and the second element is an error message, if one + occured. + + Parameter preferences: A dictionary of user search preferences. + Precondition: preferences is generated by search_for_profiles.load_preferences() + and is based on a consts.py configuration which follows the rules + outlined in that file. + """ + # Generate search terms + google = GoogleSearchAPI(c.API_KEY, c.SEARCH_ENGINE_ID) + queries = google.generate_queries(preferences) + # Search for list of terms using Google API + results = google.search(queries) + # Remove error results + # Remove error results + i = 0 + start = "https://www.linkedin.com/in" + while i < len(results[0]): + link = results[0]["Link"][i] + if link[:27] != start: + results[0] = results[0].drop(i).reset_index(drop=True) + else: + i += 1 + + results[0] = results[0].reset_index(drop=True) + + return results + +def save_results(results): + """ + Saves search results to an Excel file at the location designated in consts.py. + + Parameter results: A list of search results. + Precondition: results is a pandas DataFrame. + """ + try: + df = pd.read_excel(c.EXCEL_FILE_LOCATION) + new = pd.concat([df, results]) + new.to_excel(c.EXCEL_FILE_LOCATION, index=False) + except: + results.to_excel(c.EXCEL_FILE_LOCATION, index=False) + +def execute_search(): + """ + Orchestrates the overall execution of the LinkedIn Search Tool. + + Modifies: An Excel file to contain information about potential client + profiles the program finds via Google, and two pkl files that contain + information about previously indexed profiles and search queries. + + Returns: A String message indicating the success of the search. + """ + # Load search preferences + preferences = load_preferences() + # Search and record results, index query + search_results = run_search(preferences) + # Save results to excel file + save_results(search_results[0]) + # Return message at end of program + if search_results[1] != "": + return search_results[1] + num_added = str(len(search_results[0])) + return "Search Completed: " + num_added + " new profiles added." + diff --git a/test.py b/test.py new file mode 100644 index 0000000..45c490a --- /dev/null +++ b/test.py @@ -0,0 +1,749 @@ +""" +Test script for ConneXion. + +Author: Ethan Baker +""" +import os +import time +import random +import pickle +import pandas as pd +import numpy as np +import search_tool.search_for_profiles as search_for_profiles +import consts as c +from search_tool.google_api import GoogleSearchAPI +from search_tool.indexed_data import IndexedData +from connection_automator.linkedin_bot import LinkedinBot + +def test_load_preferences(): + # 1 Location and Position + c.LOCATIONS = ["Cazenovia"] + c.POSITIONS = ["Intern"] + c.EXPERIENCE_OPERATOR = "=" + c.EXPERIENCE_YEARS = 10 + expected = {"location":["Cazenovia"], "position":["Intern"], + "exp_op":"=", "exp_num":10} + actual = search_for_profiles.load_preferences() + assert expected == actual, "test_load_preferences failed." + + # Multiple locations and positions + c.LOCATIONS = ["Cazenovia", "Philadelphia"] + c.POSITIONS = ["Intern", "CEO", "COO"] + c.EXPERIENCE_OPERATOR = ">" + c.EXPERIENCE_YEARS = 5 + expected = {"location":["Cazenovia", "Philadelphia"], + "position":["Intern", "CEO", "COO"], + "exp_op":">", "exp_num":5} + actual = search_for_profiles.load_preferences() + assert expected == actual, "test_load_preferences failed." + print("load_preferences passed.") + +def test_save_results(): + # Create results input + titles = ["Ethan Baker - Intern", + "Alec Price - Financial Advisor", + "Ana Yavorska - Intern"] + links = ["https://www.linkedin.com/ethbak", + "www.pricefm.com", + "google.com"] + snippets = ["1 year of experience", + "33 years of experience", + "2 years of experience"] + results = pd.DataFrame() + results["Title"] = titles + results["Link"] = links + results["Snippets"] = snippets + + # Empty excel file already exists + c.EXCEL_FILE_LOCATION = r"tests/excel_tests/excel_empty.xlsx" + search_for_profiles.save_results(results) + xl = pd.read_excel(c.EXCEL_FILE_LOCATION) + assert xl.equals(results), "test_save_results failed." + reset = pd.DataFrame() + reset.to_excel(c.EXCEL_FILE_LOCATION, index=False) + + # Excel file already exists with some client data + c.EXCEL_FILE_LOCATION = r"tests/excel_tests/excel_data.xlsx" + before = pd.read_excel(c.EXCEL_FILE_LOCATION) + search_for_profiles.save_results(results) + xl = pd.read_excel(c.EXCEL_FILE_LOCATION) + titles = ["The Person", "Ethan Baker - Intern", + "Alec Price - Financial Advisor", + "Ana Yavorska - Intern"] + links = ["yelp.com", "https://www.linkedin.com/ethbak", + "www.pricefm.com", + "google.com"] + snippets = ["99 years of experience", + "1 year of experience", + "33 years of experience", + "2 years of experience"] + expected = pd.DataFrame() + expected["Title"] = titles + expected["Link"] = links + expected["Snippets"] = snippets + assert expected.equals(xl), "test_save_results failed." + before.to_excel(c.EXCEL_FILE_LOCATION, index=False) + + # Excel file already exists with other type of data + c.EXCEL_FILE_LOCATION = r"tests/excel_tests/excel_different_data.xlsx" + before = pd.read_excel(c.EXCEL_FILE_LOCATION) + search_for_profiles.save_results(results) + xl = pd.read_excel(c.EXCEL_FILE_LOCATION) + job = ["Intern", "Owner", "CEO", np.nan, np.nan, np.nan] + location = ["Cazenovia", "Voorhees", "Berlin", np.nan, np.nan, np.nan] + titles = [np.nan, np.nan, np.nan, "Ethan Baker - Intern", + "Alec Price - Financial Advisor", + "Ana Yavorska - Intern"] + links = [np.nan, np.nan, np.nan, "https://www.linkedin.com/ethbak", + "www.pricefm.com", + "google.com"] + snippets = [np.nan, np.nan, np.nan, "1 year of experience", + "33 years of experience", + "2 years of experience"] + expected = pd.DataFrame() + expected["Job Title"] = job + expected["Location"] = location + expected["Title"] = titles + expected["Link"] = links + expected["Snippets"] = snippets + assert expected.equals(xl), "test_save_results failed." + before.to_excel(c.EXCEL_FILE_LOCATION, index=False) + + # Excel file doesnt exist + c.EXCEL_FILE_LOCATION = r"tests/excel_tests/excel_new.xlsx" + search_for_profiles.save_results(results) + xl = pd.read_excel(c.EXCEL_FILE_LOCATION) + assert xl.equals(results), "test_save_results failed." + os.remove(c.EXCEL_FILE_LOCATION) + print("save_results passed.") + +def test_generate_queries(): + # 1 position and location, operator is "=" + preferences = {"location":["Cazenovia"], "position":["Intern"], + "exp_op":"=", "exp_num":10} + expected = ['site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") AND ("10 year")'] + actual = GoogleSearchAPI(c.API_KEY, c.SEARCH_ENGINE_ID).generate_queries(preferences) + assert expected == actual, "test_generate_queries failed." + + # Multiple positions and locations, operator is ">" + preferences = {"location":["Cazenovia", "Syracuse"], + "position":["Intern", "CEO"], + "exp_op":">", "exp_num":28} + expected = ['site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") AND ("28 years")', + 'site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") AND ("29 years")', + 'site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") AND ("30 years")', + 'site:linkedin.com/in intitle:("Intern") AND ("Syracuse") AND ("28 years")', + 'site:linkedin.com/in intitle:("Intern") AND ("Syracuse") AND ("29 years")', + 'site:linkedin.com/in intitle:("Intern") AND ("Syracuse") AND ("30 years")', + 'site:linkedin.com/in intitle:("CEO") AND ("Cazenovia") AND ("28 years")', + 'site:linkedin.com/in intitle:("CEO") AND ("Cazenovia") AND ("29 years")', + 'site:linkedin.com/in intitle:("CEO") AND ("Cazenovia") AND ("30 years")', + 'site:linkedin.com/in intitle:("CEO") AND ("Syracuse") AND ("28 years")', + 'site:linkedin.com/in intitle:("CEO") AND ("Syracuse") AND ("29 years")', + 'site:linkedin.com/in intitle:("CEO") AND ("Syracuse") AND ("30 years")'] + actual = GoogleSearchAPI(c.API_KEY, c.SEARCH_ENGINE_ID).generate_queries(preferences) + assert expected == actual, "test_generate_queries failed." + + # Operator is "<" + preferences = {"location":["Cazenovia"], + "position":["Intern"], + "exp_op":"<", "exp_num":3} + expected = ['site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") -"year" -"years"', + 'site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") AND ("1 year")', + 'site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") AND ("2 years")'] + actual = GoogleSearchAPI(c.API_KEY, c.SEARCH_ENGINE_ID).generate_queries(preferences) + assert expected == actual, "test_generate_queries failed." + + # operator is ">", number is 30 + preferences = {"location":["Cazenovia"], + "position":["Intern"], + "exp_op":">", "exp_num":30} + expected = ['site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") AND ("30 years")'] + actual = GoogleSearchAPI(c.API_KEY, c.SEARCH_ENGINE_ID).generate_queries(preferences) + assert expected == actual, "test_generate_queries failed." + + # operator is "<", number is 1" + preferences = {"location":["Cazenovia"], + "position":["Intern"], + "exp_op":"<", "exp_num":1} + expected = ['site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") -"year" -"years"'] + actual = GoogleSearchAPI(c.API_KEY, c.SEARCH_ENGINE_ID).generate_queries(preferences) + assert expected == actual, "test_generate_queries failed." + + # Filter out some of the search queries + c.REPEAT_QUERIES = False + c.QUERY_INDEX_LOCATION = "tests/pkl_tests/indexed_queries_generated.pkl" + preferences = {"location":["Cazenovia"], + "position":["Intern"], + "exp_op":"<", "exp_num":3} + expected = ['site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") AND ("1 year")', + 'site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") AND ("2 years")'] + actual = GoogleSearchAPI(c.API_KEY, c.SEARCH_ENGINE_ID).generate_queries(preferences) + assert expected == actual, "test_generate_queries failed." + + # Filter out all of the search queries + c.REPEAT_QUERIES = False + c.QUERY_INDEX_LOCATION = "tests/pkl_tests/indexed_queries_generated_all.pkl" + preferences = {"location":["Cazenovia"], + "position":["Intern"], + "exp_op":"<", "exp_num":3} + with open("tests/pkl_tests/indexed_queries_generated_all.pkl", 'wb') as file: + pickle.dump(['site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") -"year" -"years"', + 'site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") AND ("1 year")', + 'site:linkedin.com/in intitle:("Intern") AND ("Cazenovia") AND ("2 years")'], + file) + expected = [] + actual = GoogleSearchAPI(c.API_KEY, c.SEARCH_ENGINE_ID).generate_queries(preferences) + assert expected == actual, "test_generate_queries failed." + + print("generate_queries passed.") + +def test_load_indexed_profiles(): + # Empty file + index = IndexedData("tests/pkl_tests/indexed_profiles_empty.pkl", + "tests/pkl_tests/indexed_queries_empty.pkl") + assert index.profiles == [], "test_load_indexed_profiles failed." + + # File with some data already in it + index = IndexedData("tests/pkl_tests/indexed_profiles_full.pkl", + "tests/pkl_tests/indexed_queries_full.pkl") + assert index.profiles == ["alec", "ethan", "sharon"], "test_load_indexed_profiles failed." + print("load_indexed_profiles passed.") + +def test_check_dup_profile(): + # No duplicate + index = IndexedData("tests/pkl_tests/indexed_profiles_full.pkl", + "tests/pkl_tests/indexed_queries_full.pkl") + assert index.check_dup_profile("Anastasiya Yavorska") is False, "test_check_dup_profile failed." + + # Duplicate + assert index.check_dup_profile("ethan") is True, "test_check_dup_profile failed." + print("check_dup_profile passed.") + +def test_add_indexed_profile(): + # Add to empty list + index = IndexedData("tests/pkl_tests/indexed_profiles_empty.pkl", + "tests/pkl_tests/indexed_queries_empty.pkl") + index.add_indexed_profile("fred") + assert index.profiles == ['fred'], "test_add_indexed_profile failed." + + # Add to full list + index = IndexedData("tests/pkl_tests/indexed_profiles_full.pkl", + "tests/pkl_tests/indexed_queries_full.pkl") + index.add_indexed_profile("fred") + assert index.profiles == ['alec', 'ethan', 'fred', 'sharon'], "test_add_indexed_profile failed." + print("add_indexed_profile passed.") + +def test_save_indexed_profiles(): + # Empty list + index = IndexedData("tests/pkl_tests/indexed_profiles_full.pkl", + "tests/pkl_tests/indexed_queries_full.pkl") + index.profile_file = "tests/pkl_tests/save.pkl" + index.profiles = [] + index.save_indexed_profiles() + assert IndexedData("tests/pkl_tests/save.pkl", + "tests/pkl_tests/indexed_queries_full.pkl").profiles == [], "test_save_indexed_profiles failed." + + # Full list + index = IndexedData("tests/pkl_tests/indexed_profiles_full.pkl", + "tests/pkl_tests/indexed_queries_full.pkl") + index.profile_file = "tests/pkl_tests/save.pkl" + index.profiles = ['alec','anastasiya','ethan','zoe'] + index.save_indexed_profiles() + assert IndexedData("tests/pkl_tests/save.pkl", + "tests/pkl_tests/indexed_queries_full.pkl").profiles == index.profiles, "test_save_indexed_profiles failed." + + os.remove("tests/pkl_tests/save.pkl") + with open ("tests/pkl_tests/save.pkl", 'wb'): + pass + + print("save_indexed_profiles passed.") + +def test_load_indexed_queries(): + # Empty file + index = IndexedData("tests/pkl_tests/indexed_profiles_empty.pkl", + "tests/pkl_tests/indexed_queries_empty.pkl") + assert index.queries == [], "test_load_indexed_queries failed." + + # File with some data already in it + index = IndexedData("tests/pkl_tests/indexed_profiles_full.pkl", + "tests/pkl_tests/indexed_queries_full.pkl") + assert index.queries == ["CEO", "Manager", "Owner"], "test_load_indexed_queries failed." + print("load_indexed_queries passed.") + +def test_check_dup_query(): + # No duplicate + index = IndexedData("tests/pkl_tests/indexed_profiles_full.pkl", + "tests/pkl_tests/indexed_queries_full.pkl") + assert index.check_dup_query("Chief Executive Officer") is False, "test_check_dup_query failed." + + # Duplicate + assert index.check_dup_query("CEO") is True, "test_check_dup_query failed." + print("check_dup_query passed.") + +def test_add_indexed_query(): + # Add to empty list + index = IndexedData("tests/pkl_tests/indexed_profiles_empty.pkl", + "tests/pkl_tests/indexed_queries_empty.pkl") + index.add_indexed_query("CFO") + assert index.queries == ['CFO'], "test_add_indexed_query failed." + + # Add to full list + index = IndexedData("tests/pkl_tests/indexed_profiles_full.pkl", + "tests/pkl_tests/indexed_queries_full.pkl") + index.add_indexed_query("CFO") + assert index.queries == ['CEO', 'CFO', 'Manager', 'Owner'], "test_add_indexed_query failed." + print("add_indexed_query passed.") + +def test_save_indexed_queries(): + # Empty list + index = IndexedData("tests/pkl_tests/indexed_profiles_full.pkl", + "tests/pkl_tests/indexed_queries_full.pkl") + index.query_file = "tests/pkl_tests/save.pkl" + index.queries = [] + index.save_indexed_queries() + assert IndexedData("tests/pkl_tests/indexed_profiles_full.pkl", + "tests/pkl_tests/save.pkl").queries == [], "test_save_indexed_queries failed." + + # Full list + index = IndexedData("tests/pkl_tests/indexed_profiles_full.pkl", + "tests/pkl_tests/indexed_queries_full.pkl") + index.query_file = "tests/pkl_tests/save.pkl" + index.queries = ['CEO', 'CFO', 'Intern', 'Manager', 'Owner'] + index.save_indexed_queries() + assert IndexedData("tests/pkl_tests/indexed_profiles_full.pkl", + "tests/pkl_tests/save.pkl").queries == index.queries, "test_save_indexed_queries failed." + + os.remove("tests/pkl_tests/save.pkl") + with open ("tests/pkl_tests/save.pkl", 'wb'): + pass + + print("save_indexed_queries passed.") + +def test_login(): + # Valid login + c.USERNAME = "8ethanbaker@gmail.com" + c.PASSWORD = "Testpassword123!" + bot1 = LinkedinBot() + result = bot1.login() + assert result, "test_login failed." + bot1.driver.quit() + + # Invalid login + c.USERNAME = "hello" + c.PASSWORD = "world" + bot2 = LinkedinBot() + result = bot2.login() + assert not result, "test_login failed." + bot2.driver.quit() + + time.sleep(random.uniform(3,5)) + print("login passed.") + +def test_send_connection_request(): + c.USERNAME = "8ethanbaker@gmail.com" + c.PASSWORD = "Testpassword123!" + bot1 = LinkedinBot() + bot1.login() + + # Has a connect button + bot1.driver.get("https://www.linkedin.com/in/daniel-lines-2b2045232/") + time.sleep(random.uniform(3,5)) + assert bot1.send_connection_request("Lets connect!"), "test_send_connection_request failed." + + # Follow instead of connect + bot1.driver.get("https://www.linkedin.com/in/mark-cuban-06a0755b/") + time.sleep(random.uniform(3,5)) + assert not bot1.send_connection_request("Lets connect!"), "test_send_connection_request failed." + + # Has a connect button but requires more info to send request + bot1.driver.get("https://www.linkedin.com/in/david-m-solomon/") + time.sleep(random.uniform(3,5)) + assert not bot1.send_connection_request("Lets connect!"), "test_send_connection_request failed." + + # Is already a connection + bot1.driver.get("https://www.linkedin.com/in/ethbak/") + time.sleep(random.uniform(3,5)) + assert not bot1.send_connection_request("Lets connect!"), "test_send_connection_request failed." + + # Profile has an outgoing connection request towards the user + bot1.driver.get("https://www.linkedin.com/in/ayavorska/") + time.sleep(random.uniform(3,5)) + assert not bot1.send_connection_request("Lets connect!"), "test_send_connection_request failed." + + # User has already sent the profile a request + bot1.driver.get("https://www.linkedin.com/in/ethanbakertest/") + time.sleep(random.uniform(3,5)) + assert not bot1.send_connection_request("Lets connect!"), "test_send_connection_request failed." + + bot1.driver.quit() + print("send_connection_request passed.") + +def test_more_then_connect(): + c.USERNAME = "8ethanbaker@gmail.com" + c.PASSWORD = "Testpassword123!" + bot1 = LinkedinBot() + bot1.login() + + # Has a connect button + bot1.driver.get("https://www.linkedin.com/in/daniel-lines-2b2045232/") + time.sleep(random.uniform(3,5)) + assert not bot1.more_then_connect("Lets connect!"), "test_more_then_connect failed." + + # Has hidden connect button + bot1.driver.get("https://www.linkedin.com/in/mark-cuban-06a0755b/") + time.sleep(random.uniform(3,5)) + assert bot1.more_then_connect("Lets connect!"), "test_more_then_connect failed." + + # Following + bot1.driver.get("https://www.linkedin.com/in/david-m-solomon/") + time.sleep(random.uniform(3,5)) + assert not bot1.more_then_connect("Lets connect!"), "test_more_then_connect failed." + + # Is already a connection + bot1.driver.get("https://www.linkedin.com/in/ethbak/") + time.sleep(random.uniform(3,5)) + assert not bot1.more_then_connect("Lets connect!"), "test_more_then_connect failed." + + # Profile has an outgoing connection request towards the user + bot1.driver.get("https://www.linkedin.com/in/ayavorska/") + time.sleep(random.uniform(3,5)) + assert not bot1.more_then_connect("Lets connect!"), "test_more_then_connect failed." + + # User has already sent the profile a request + bot1.driver.get("https://www.linkedin.com/in/ethanbakertest/") + time.sleep(random.uniform(3,5)) + assert not bot1.more_then_connect("Lets connect!"), "test_more_then_connect failed." + + # Not famous but has hidden connect button + bot1.driver.get("https://www.linkedin.com/in/marc-sperg-a4696721/") + time.sleep(random.uniform(3,5)) + assert bot1.more_then_connect("Lets connect!"), "test_more_then_connect failed." + + bot1.driver.quit() + print("more_then_connect passed.") + +def test_accept_request(): + c.USERNAME = "8ethanbaker@gmail.com" + c.PASSWORD = "Testpassword123!" + bot1 = LinkedinBot() + bot1.login() + + # Has a connect button + bot1.driver.get("https://www.linkedin.com/in/daniel-lines-2b2045232/") + time.sleep(random.uniform(3,5)) + assert not bot1.accept_request(), "test_accept_request failed." + + # Follow instead of connect + bot1.driver.get("https://www.linkedin.com/in/mark-cuban-06a0755b/") + time.sleep(random.uniform(3,5)) + assert not bot1.accept_request(), "test_accept_request failed." + + # Has a connect button but requires more info to send request + bot1.driver.get("https://www.linkedin.com/in/david-m-solomon/") + time.sleep(random.uniform(3,5)) + assert not bot1.accept_request(), "test_accept_request failed." + + # Is already a connection + bot1.driver.get("https://www.linkedin.com/in/ethbak/") + time.sleep(random.uniform(3,5)) + assert not bot1.accept_request(), "test_accept_request failed." + + # Profile has an outgoing connection request towards the user + bot1.driver.get("https://www.linkedin.com/in/ayavorska/") + time.sleep(random.uniform(3,5)) + assert bot1.accept_request(), "test_accept_request failed." + + # User has already sent the profile a request + bot1.driver.get("https://www.linkedin.com/in/ethanbakertest/") + time.sleep(random.uniform(3,5)) + assert not bot1.accept_request(), "test_accept_request failed." + + bot1.driver.quit() + print("accept_request passed.") + +def test_extract_connection_count(): + # Login + c.USERNAME = "8ethanbaker@gmail.com" + c.PASSWORD = "Testpassword123!" + bot1 = LinkedinBot() + bot1.login() + + # No connections + bot1.driver.get("https://www.linkedin.com/in/ethbaktest") + assert bot1.extract_connection_count() == 0, "test_extract_connection_count failed." + time.sleep(random.uniform(3,5)) + + # 0 < connections < 500 + bot1.driver.get("https://www.linkedin.com/in/ethanbakertest") + assert bot1.extract_connection_count() == 1, "test_extract_connection_count failed." + time.sleep(random.uniform(3,5)) + + # 500+ connections and someone who has already connected with + bot1.driver.get("https://www.linkedin.com/in/ethbak") + assert bot1.extract_connection_count() == 500, "test_extract_connection_count failed." + + # Shows follower count and connection count + bot1.driver.get("https://www.linkedin.com/in/aryaaagarwal/") + assert bot1.extract_connection_count() > 730, "test_extract_connection_count failed." + + # Only shows follower count + bot1.driver.get("https://www.linkedin.com/in/arnesorenson/") + assert bot1.extract_connection_count() > 800000, "test_extract_connection_count failed" + + time.sleep(random.uniform(3,5)) + print("extract_connection_count_passed") + +def test_has_connect_button(): + c.USERNAME = "8ethanbaker@gmail.com" + c.PASSWORD = "Testpassword123!" + bot1 = LinkedinBot() + bot1.login() + + # Has a connect button + bot1.driver.get("https://www.linkedin.com/in/daniel-lines-2b2045232/") + time.sleep(random.uniform(3,5)) + assert bot1.has_connect_button(), "test_has_connect_button failed." + + # Follow instead of connect + bot1.driver.get("https://www.linkedin.com/in/mark-cuban-06a0755b/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_connect_button(), "test_has_connect_button failed." + + # Following instead of connect + bot1.driver.get("https://www.linkedin.com/in/david-m-solomon/") + time.sleep(random.uniform(3,5)) + assert bot1.has_connect_button(), "test_has_connect_button failed." + + # Is already a connection + bot1.driver.get("https://www.linkedin.com/in/ethbak/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_connect_button(), "test_has_connect_button failed." + + # Profile has an outgoing connection request towards the user + bot1.driver.get("https://www.linkedin.com/in/ayavorska/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_connect_button(), "test_has_connect_button failed." + + # User has already sent the profile a request + bot1.driver.get("https://www.linkedin.com/in/ethanbakertest/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_connect_button(), "test_has_connect_button failed." + + bot1.driver.quit() + print("has_connect_button passed.") + +def test_has_accept_button(): + c.USERNAME = "8ethanbaker@gmail.com" + c.PASSWORD = "Testpassword123!" + bot1 = LinkedinBot() + bot1.login() + + # Profile has an outgoing connection request towards the user + bot1.driver.get("https://www.linkedin.com/in/ayavorska/") + time.sleep(random.uniform(3,5)) + assert bot1.has_accept_button(), "test_has_accept_button failed." + + # Has a connect button + bot1.driver.get("https://www.linkedin.com/in/daniel-lines-2b2045232/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_accept_button(), "test_has_accept_button failed." + + # Follow instead of connect + bot1.driver.get("https://www.linkedin.com/in/mark-cuban-06a0755b/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_accept_button(), "test_has_accept_button failed." + + # Following instead of connect + bot1.driver.get("https://www.linkedin.com/in/david-m-solomon/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_accept_button(), "test_has_accept_button failed." + + # Is already a connection + bot1.driver.get("https://www.linkedin.com/in/ethbak/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_accept_button(), "test_has_accept_button failed." + + # User has already sent the profile a request + bot1.driver.get("https://www.linkedin.com/in/ethanbakertest/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_accept_button(), "test_has_accept_button failed." + + bot1.driver.quit() + print("has_accept_button passed.") + +def test_has_hidden_connect_button(): + c.USERNAME = "8ethanbaker@gmail.com" + c.PASSWORD = "Testpassword123!" + bot1 = LinkedinBot() + bot1.login() + + # Profile has an outgoing connection request towards the user (No hidden button) + bot1.driver.get("https://www.linkedin.com/in/ayavorska/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_hidden_connect_button(), "test_has_hidden_connect_button failed." + + # Has a connect button (No hidden button) + bot1.driver.get("https://www.linkedin.com/in/daniel-lines-2b2045232/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_hidden_connect_button(), "test_has_hidden_connect_button failed." + + # Follow instead of connect (Has a button) + bot1.driver.get("https://www.linkedin.com/in/mark-cuban-06a0755b/") + time.sleep(random.uniform(3,5)) + assert bot1.has_hidden_connect_button(), "test_has_hidden_connect_button failed." + + # Following instead of connect (No hidden button) + bot1.driver.get("https://www.linkedin.com/in/david-m-solomon/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_hidden_connect_button(), "test_has_hidden_connect_button failed." + + # Is already a connection (No button) + bot1.driver.get("https://www.linkedin.com/in/ethbak/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_hidden_connect_button(), "test_has_hidden_connect_button failed." + + # User has already sent the profile a request (No button) + bot1.driver.get("https://www.linkedin.com/in/ethanbakertest/") + time.sleep(random.uniform(3,5)) + assert not bot1.has_hidden_connect_button(), "test_has_hidden_connect_button failed." + +def test_write_message(): + # No custom features + c.USERNAME = "8ethanbaker@gmail.com" + c.PASSWORD = "Testpassword123!" + c.MESSAGE = "Hello there!" + bot1 = LinkedinBot() + bot1.login() + bot1.driver.get("https://www.linkedin.com/in/ethbak") + time.sleep(random.uniform(3,5)) + + msg = bot1.write_message() + assert msg == "Hello there!", "test_write_message failed." + bot1.driver.quit() + + # Custom full name + c.MESSAGE = "Hi [FULL NAME]! Nice to meet you!" + bot2 = LinkedinBot() + bot2.login() + bot2.driver.get("https://www.linkedin.com/in/ethbak") + time.sleep(random.uniform(3,5)) + + msg = bot2.write_message() + assert msg == "Hi Ethan Baker! Nice to meet you!", "test_write_message failed." + bot2.driver.quit() + + # Custom first name + c.MESSAGE = "Hi [FIRST NAME]! Nice to meet you!" + bot3 = LinkedinBot() + bot3.login() + bot3.driver.get("https://www.linkedin.com/in/ethbak") + time.sleep(random.uniform(3,5)) + + msg = bot3.write_message() + assert msg == "Hi Ethan! Nice to meet you!", "test_write_message failed." + bot3.driver.quit() + + # Name starts with a Dr. + c.MESSAGE = "Hi [FIRST NAME]! Nice to meet you!" + bot4 = LinkedinBot() + bot4.login() + bot4.driver.get("https://www.linkedin.com/in/dambisamoyo/") + time.sleep(random.uniform(3,5)) + + msg = bot4.write_message() + assert msg == "Hi Dambisa! Nice to meet you!", "test_write_message failed." + bot4.driver.quit() + + print("write_message passed.") + +def test_check_weekly_limit(): + # No limit message + c.USERNAME = "8ethanbaker@gmail.com" + c.PASSWORD = "Testpassword123!" + bot1 = LinkedinBot() + bot1.login() + + bot1.driver.get("https://www.linkedin.com/in/brandon-wu-60aa26266/") + time.sleep(random.uniform(3,5)) + bot1.send_connection_request("Lets connect!") + time.sleep(random.uniform(3,5)) + assert not bot1.check_weekly_limit(), "test_check_weekly_limit failed." + bot1.driver.quit() + + # Limit message + c.USERNAME = "8ethanbaker@gmail.com" + c.PASSWORD = "Testpassword123!" + bot2 = LinkedinBot() + bot2.login() + + bot2.driver.get("https://www.linkedin.com/in/kinza-ceesay-0845b41a6/") + time.sleep(random.uniform(3,5)) + bot2.send_connection_request("Lets connect!") + time.sleep(random.uniform(3,5)) + assert bot2.check_weekly_limit(), "test_check_weekly_limit failed." + bot2.driver.quit() + + print("check_weekly_limit passed.") + +def test_search_for_profiles(): + """ + Tests all functions in search_for_profiles + """ + test_load_preferences() + test_save_results() + print("All search_for_profiles.py functions passed.") + +def test_google_api(): + """ + Tests all methods in the class GoogleSearchAPI + Does not test search as it uses API requests. + """ + test_generate_queries() + print("All GoogleSearchAPI methods passed.") + +def test_indexed_data(): + """ + Tests all methods in the class IndexedData. + """ + test_load_indexed_profiles() + test_check_dup_profile() + test_add_indexed_profile() + test_save_indexed_profiles() + test_load_indexed_queries() + test_check_dup_query() + test_add_indexed_query() + test_save_indexed_queries() + print("All IndexedData methods passed.") + +def test_linkedin_bot(): + """ + Tests all methods in the class LinkedinBot. + + Some of the test cases WILL FAIL as the connect / accept + methods change the state of the LinkedIn profiles used in the tests. + """ + test_login() + test_send_connection_request() + test_more_then_connect() + test_accept_request() + test_extract_connection_count() + test_write_message() + test_has_connect_button() + test_has_hidden_connect_button() + test_has_accept_button() + test_check_weekly_limit() + print("All LinkedinBot methods passed.") + +def test_all(): + """ + Tests all functions for the Linkedin Client Search Tool. + """ + test_search_for_profiles() + test_google_api() + test_indexed_data() + test_linkedin_bot() + print("All functions passed.") + +if __name__ == "__main__": + test_all() diff --git a/tests/excel_tests/excel_data.xlsx b/tests/excel_tests/excel_data.xlsx new file mode 100644 index 0000000..4cda43f Binary files /dev/null and b/tests/excel_tests/excel_data.xlsx differ diff --git a/tests/excel_tests/excel_different_data.xlsx b/tests/excel_tests/excel_different_data.xlsx new file mode 100644 index 0000000..b0cc9fd Binary files /dev/null and b/tests/excel_tests/excel_different_data.xlsx differ diff --git a/tests/excel_tests/excel_empty.xlsx b/tests/excel_tests/excel_empty.xlsx new file mode 100644 index 0000000..873c1a6 Binary files /dev/null and b/tests/excel_tests/excel_empty.xlsx differ diff --git a/tests/pkl_tests/index_queries_generated.pkl b/tests/pkl_tests/index_queries_generated.pkl new file mode 100644 index 0000000..e69de29 diff --git a/tests/pkl_tests/indexed_profiles_empty.pkl b/tests/pkl_tests/indexed_profiles_empty.pkl new file mode 100644 index 0000000..e69de29 diff --git a/tests/pkl_tests/indexed_profiles_full.pkl b/tests/pkl_tests/indexed_profiles_full.pkl new file mode 100644 index 0000000..8f81924 Binary files /dev/null and b/tests/pkl_tests/indexed_profiles_full.pkl differ diff --git a/tests/pkl_tests/indexed_queries_empty.pkl b/tests/pkl_tests/indexed_queries_empty.pkl new file mode 100644 index 0000000..e69de29 diff --git a/tests/pkl_tests/indexed_queries_full.pkl b/tests/pkl_tests/indexed_queries_full.pkl new file mode 100644 index 0000000..2913786 Binary files /dev/null and b/tests/pkl_tests/indexed_queries_full.pkl differ diff --git a/tests/pkl_tests/indexed_queries_generated.pkl b/tests/pkl_tests/indexed_queries_generated.pkl new file mode 100644 index 0000000..86216d1 Binary files /dev/null and b/tests/pkl_tests/indexed_queries_generated.pkl differ diff --git a/tests/pkl_tests/indexed_queries_generated_all.pkl b/tests/pkl_tests/indexed_queries_generated_all.pkl new file mode 100644 index 0000000..31bd414 Binary files /dev/null and b/tests/pkl_tests/indexed_queries_generated_all.pkl differ diff --git a/tests/pkl_tests/indexed_queries_search.pkl b/tests/pkl_tests/indexed_queries_search.pkl new file mode 100644 index 0000000..1df9f59 Binary files /dev/null and b/tests/pkl_tests/indexed_queries_search.pkl differ diff --git a/tests/pkl_tests/save.pkl b/tests/pkl_tests/save.pkl new file mode 100644 index 0000000..e69de29