├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets └── ascii_art.txt ├── requirements.txt └── src ├── main.py └── utilities.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [FujiwaraChoki] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /geckodriver.log 2 | /output 3 | __pycache__ 4 | .history 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 FujiwaraChoki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LinkedIn Bot 2 | 3 | Automatically connect with people on LinkedIn, for your business, or for meeting new people. 4 | 5 | ## Features 6 | 7 | - Provide a JSON file of people you want to connect with 8 | - Scrape & Connect with people, all in one go 9 | - Automatically send a message to the people you connect with 10 | 11 | ## Installation 12 | 13 | Use `git` to clone the repository. 14 | 15 | ```bash 16 | git clone https://github.com/FujiwaraChoki/linkedin-bot.git 17 | ``` 18 | 19 | Then, install the dependencies. 20 | 21 | ```bash 22 | pip install -r requirements.txt 23 | ``` 24 | 25 | ## Usage 26 | 27 | > Before running the bot, make sure you have a Firefox profile with your LinkedIn account logged in. 28 | 29 | > Also, tweak the parameters in the `src/main.py` file to your liking, for example due to your language. 30 | 31 | To show the help message, run the following command. 32 | 33 | ```bash 34 | python src/main.py --help 35 | ``` 36 | 37 | To run the bot, follow this template: 38 | 39 | ```bash 40 | python src/main.py --profile {str} (Path to your Firefox profile) [OPTIONAL: --headless, --query {str} (Your search query (or your niche)), --people (Where your JSON file is located), --n {num} (Amount of people you want to connect with, if not supplied, default 30 is used)] 41 | ``` 42 | 43 | ### Example JSON-File 44 | 45 | ```json 46 | [ 47 | { 48 | "pfp": "", 49 | "name": "", 50 | "profile_url": "", 51 | "subtitle": "", 52 | "secondary_subtitle": "", 53 | "summary": "" 54 | }, 55 | ... 56 | ] 57 | ``` 58 | 59 | ## Contributing 60 | 61 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 62 | 63 | ## License 64 | 65 | [MIT](LICENSE) 66 | 67 | ## Author 68 | 69 | - [FujiwaraChoki](https://github.com/FujiwaraChoki) 70 | -------------------------------------------------------------------------------- /assets/ascii_art.txt: -------------------------------------------------------------------------------- 1 | 2 | _ _ _ _ _____ ____ _ 3 | | | (_) | | | |_ _| | _ \ | | 4 | | | _ _ __ | | _____ __| | | | _ __ | |_) | ___ | |_ 5 | | | | | '_ \| |/ / _ \/ _` | | | | '_ \ | _ < / _ \| __| 6 | | |____| | | | | < __/ (_| |_| |_| | | | | |_) | (_) | |_ 7 | |______|_|_| |_|_|\_\___|\__,_|_____|_| |_| |____/ \___/ \__| 8 | 9 | 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | termcolor 2 | selenium==4.9.0 3 | selenium_firefox==0.1.0 4 | webdriver_manager 5 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from utilities import * 4 | from selenium_firefox import * 5 | from selenium import webdriver 6 | from selenium.webdriver.common.by import By 7 | from selenium.webdriver.firefox.service import Service 8 | from selenium.webdriver.firefox.options import Options 9 | from webdriver_manager.firefox import GeckoDriverManager 10 | 11 | # Enter your search query or keywords here 12 | SEARCH_QUERY = "Restaurant owner" 13 | 14 | # Enter the GEO_URN of your location 15 | # you can find it by searching for your location on LinkedIn 16 | GEO_URN = "[\"103644278\"]" # Current location is `United States` 17 | 18 | # This determines whether the script will ask you before sending the connection request 19 | # to someone, or not. 20 | ASK_BEFORE_SENDING = False 21 | 22 | # How many people you want to connect to 23 | N_SEARCH_RESULTS = 100 24 | 25 | # The message you want to send to the people 26 | # The only variable right now is {{name}}, which will be replaced by the person's name 27 | MESSAGE_WITH_NAME = "Hello {{name}}, I would love to connect with you! I hope you are having a great day." 28 | MESSAGE_WITHOUT_NAME = "Hello, I would love to connect with you! I hope you are having a great day." 29 | 30 | # From which page to start getting people 31 | CURRENT_PAGE = 1 32 | 33 | # IMPORTANT 34 | # Change this depending on your language 35 | CONTENT_OF_ADD_MESSAGE_BUTTON = "Nachricht hinzufügen" 36 | CONTENT_OF_SEND_BUTTON = "Senden" 37 | CONTENT_OF_MORE_BUTTON = "Mehr" 38 | CONTENT_OF_CONNECTION_BUTTON = "Vernetzen" 39 | 40 | # ---- DON'T TOUCH ---- 41 | PEOPLE = [] 42 | MAX_PAGES = 10 43 | BASE_LINKEDIN_URL = "https://www.linkedin.com" 44 | RESULTS_LIST_CLASS = "reusable-search__entity-result-list" 45 | PAGINATION_LIST_CLASS = "artdeco-pagination__pages" 46 | PERSON_NAME_CLASS = "entity-result__title-text" 47 | PERSON_SUBTITLE_CLASS = "entity-result__primary-subtitle" 48 | PERSON_SECONDARY_SUBTITLE_CLASS = "entity-result__secondary-subtitle" 49 | PERSON_SUMMARY_CLASS = "entity-result__summary" 50 | PERSON_ACTION_BUTTON = "entity-result__actions" 51 | ACTION_CONTAINER = "IZLqPHsLPvmtnxTRikvwkBUVdnExLjJlPwssjbtQ" # Untested, might not work. Open an issue if it does not. 52 | MODAL_ACTION_BAR = "artdeco-modal__actionbar" 53 | 54 | def main(): 55 | """ 56 | Main function, where all the magic happens. 57 | """ 58 | global PEOPLE 59 | global MAX_PAGES 60 | global SEARCH_QUERY 61 | global CURRENT_PAGE 62 | global N_SEARCH_RESULTS 63 | 64 | # Print Art 65 | print_ascii_art() 66 | 67 | if "--help" in sys.argv: 68 | print(colored("[*] Help:", "magenta")) 69 | print(colored(" --n - Specify the number of search results to scrape.", "magenta")) 70 | print(colored(" --profile - Specify the location of your Firefox profile.", "magenta")) 71 | print(colored(" --headless - Specify if you want to run the script headless or not.", "magenta")) 72 | print(colored(" --query - Specify the query you want to search for.", "magenta")) 73 | print(colored(" --people - Specify the file you want to load people from.", "magenta")) 74 | print(colored(" --help - Show this help message.\n\n", "magenta")) 75 | return 76 | 77 | # Close all Firefox instances 78 | close_all_firefox_instances() 79 | 80 | # Starting Message 81 | start_message() 82 | 83 | # Prepare folder structure 84 | prepare_strucutre() 85 | 86 | # Check if user supplied --n flag 87 | temp_n_search_results = get_n_search_results(sys.argv) 88 | 89 | if temp_n_search_results: 90 | N_SEARCH_RESULTS = temp_n_search_results 91 | 92 | # Load people from file 93 | temp_people = get_people_list_from_file(sys.argv) 94 | 95 | if temp_people: 96 | print(colored(f"[+] Loaded {len(temp_people)} people from file.", "green")) 97 | PEOPLE = temp_people 98 | 99 | # See if user supplied a custom query 100 | temp_query = get_query(sys.argv) 101 | 102 | if temp_query: 103 | SEARCH_QUERY = temp_query 104 | 105 | # Get Firefox Profile Location and check if it is valid 106 | firefox_profile_location = get_firefox_profile_location(sys.argv) 107 | check_profile_location(firefox_profile_location) 108 | 109 | # Get headless option 110 | headless = get_headless(sys.argv) 111 | 112 | # Instantiate Firefox Options 113 | options = Options() 114 | 115 | if headless: 116 | options.add_argument("--headless") 117 | 118 | # Bypass the bug in Selenium using this method 119 | # More Information: https://github.com/SeleniumHQ/selenium/issues/11028 120 | options.add_argument("-profile") 121 | options.add_argument(firefox_profile_location) 122 | 123 | # Instantiate Firefox Service 124 | service = Service(GeckoDriverManager().install()) 125 | 126 | # Instantiate Firefox Driver 127 | driver = webdriver.Firefox(service=service, options=options) 128 | 129 | if not temp_people: 130 | # Go to LinkedIn 131 | driver.get(f"{BASE_LINKEDIN_URL}/search/results/people/?geoUrn={GEO_URN}&keywords={SEARCH_QUERY}&origin=SWITCH_SEARCH_VERTICAL&sid=p%2CR") 132 | 133 | # Set full screen 134 | driver.maximize_window() 135 | 136 | # Wait for page load 137 | wait(4) 138 | 139 | # Scroll to bottom of page 140 | scroll_to_bottom(driver) 141 | 142 | wait(2) 143 | 144 | # Get pagination list 145 | pagination_list = driver.find_element(By.CLASS_NAME, PAGINATION_LIST_CLASS) 146 | 147 | # Get last `li` element's text (inside of `span`) 148 | last_page_number = int(pagination_list.find_elements(By.TAG_NAME, "li")[-1].find_element(By.TAG_NAME, "span").text) 149 | MAX_PAGES = last_page_number 150 | for _ in range(MAX_PAGES): 151 | if len(PEOPLE) >= N_SEARCH_RESULTS: 152 | break 153 | 154 | # Print current page message 155 | print(colored(f"[+] Navigating to page {CURRENT_PAGE}...", "yellow")) 156 | 157 | # Go to Page URL 158 | driver.get(f"{BASE_LINKEDIN_URL}/search/results/people/?geoUrn={GEO_URN}&keywords={SEARCH_QUERY}&origin=SWITCH_SEARCH_VERTICAL&sid=p%2CR&page={CURRENT_PAGE}") 159 | 160 | # Get results list 161 | results_list = driver.find_element(By.CLASS_NAME, RESULTS_LIST_CLASS) 162 | 163 | # Get all `li` elements inside of the results list 164 | results = results_list.find_elements(By.TAG_NAME, "li") 165 | 166 | # Iterate over results, get their information 167 | for result in results: 168 | 169 | # Get PFP URL 170 | try: 171 | pfp = result.find_element(By.TAG_NAME, "img").get_attribute("src") 172 | except: 173 | pfp = "" 174 | 175 | # Get Profile URL 176 | try: 177 | profile_url = result.find_elements(By.TAG_NAME, "a") 178 | for url in profile_url: 179 | if "/in/" in url.get_attribute("href"): 180 | profile_url = url.get_attribute("href") 181 | break 182 | else: 183 | profile_url = "" 184 | except: 185 | profile_url = "" 186 | 187 | # Get Name 188 | try: 189 | name = result.find_element(By.CLASS_NAME, PERSON_NAME_CLASS).find_elements(By.TAG_NAME, "span")[1].text 190 | except: 191 | continue 192 | 193 | # Get Subtitle 194 | try: 195 | subtitle = result.find_element(By.CLASS_NAME, PERSON_SUBTITLE_CLASS).text 196 | except: 197 | subtitle = "" 198 | 199 | # Get Secondary Subtitle 200 | try: 201 | secondary_subtitle = result.find_element(By.CLASS_NAME, PERSON_SECONDARY_SUBTITLE_CLASS).text 202 | except: 203 | secondary_subtitle = "" 204 | 205 | # Get Summary 206 | try: 207 | summary = result.find_element(By.CLASS_NAME, PERSON_SUMMARY_CLASS).text 208 | except: 209 | summary = "" 210 | 211 | 212 | PEOPLE.append({ 213 | "pfp": pfp, 214 | "name": name, 215 | "profile_url": profile_url, 216 | "subtitle": subtitle, 217 | "secondary_subtitle": secondary_subtitle, 218 | "summary": summary 219 | }) 220 | 221 | # Increment current page 222 | CURRENT_PAGE += 1 223 | 224 | # Remove every person without profile_url 225 | PEOPLE = [person for person in PEOPLE if person["profile_url"]] 226 | 227 | # Save to JSON 228 | save_to_json(PEOPLE) 229 | 230 | # Tell user how many people were found 231 | print(colored(f"[+] Found {len(PEOPLE)} people.", "green")) 232 | 233 | input(colored("[?] Press any key to start sending connection requests...", "magenta")) 234 | 235 | # ----------------------------------------------------------- # 236 | # This is where the script begins sending connection requests # 237 | # ----------------------------------------------------------- # 238 | 239 | for person in PEOPLE: 240 | print(colored(f"[+] Sending connection request to {person['name'] if person['name'] else person['subtitle']}...", "yellow")) 241 | 242 | # Navigate to profile url 243 | driver.get(person["profile_url"]) 244 | 245 | # Wait for page load 246 | wait(2) 247 | 248 | # Get action container 249 | action_container = driver.find_element(By.CLASS_NAME, ACTION_CONTAINER) 250 | 251 | # Get all buttons 252 | buttons = action_container.find_elements(By.TAG_NAME, "button") 253 | 254 | # Iterate over buttons 255 | for button in buttons: 256 | # Check if button contains the text "Vernetzen" 257 | try: 258 | if CONTENT_OF_CONNECTION_BUTTON in button.find_element(By.TAG_NAME, "span").text: 259 | # Click the button 260 | button.click() 261 | break 262 | except: 263 | continue 264 | else: 265 | # WARNING: This does not work yet 266 | print(colored("[!] Could not find the connection button the conventional way.", "red")) 267 | 268 | """ 269 | try: 270 | # Get all buttons 271 | buttons = action_container.find_elements(By.TAG_NAME, "button") 272 | 273 | # Iterate over buttons 274 | for button in buttons: 275 | # Check if button contains the text "Mehr" 276 | try: 277 | if CONTENT_OF_MORE_BUTTON in button.find_element(By.TAG_NAME, "span").text: 278 | # Click the button 279 | button.click() 280 | break 281 | except: 282 | continue 283 | 284 | # artdeco-dropdown__content-inner 285 | dropdown = driver.find_element(By.CLASS_NAME, "artdeco-dropdown__content-inner").find_element(By.TAG_NAME, "ul") 286 | 287 | # Get all `span` elements 288 | dropdown_items = dropdown.find_elements(By.TAG_NAME, "span") 289 | 290 | for item in dropdown_items: 291 | if CONTENT_OF_CONNECTION_BUTTON in item.text: 292 | # Get parent `div` element 293 | parent = item.find_element(By.XPATH, "..") 294 | 295 | # Click the parent element 296 | parent.click() 297 | 298 | break 299 | except: 300 | continue 301 | """ 302 | try: 303 | # Get modal action bar 304 | modal_action_bar = driver.find_element(By.CLASS_NAME, MODAL_ACTION_BAR) 305 | 306 | # Get all buttons 307 | buttons = modal_action_bar.find_elements(By.TAG_NAME, "button") 308 | 309 | # Iterate over buttons 310 | for button in buttons: 311 | # Check if button contains the text "Nachricht hinzufügen" 312 | try: 313 | if CONTENT_OF_ADD_MESSAGE_BUTTON in button.find_element(By.TAG_NAME, "span").text: 314 | # Click the button 315 | button.click() 316 | break 317 | except: 318 | continue 319 | else: 320 | print(colored("[!] Could not find the add message button.", "red")) 321 | continue 322 | 323 | # Find custom message textarea, by ID 324 | custom_message_textarea = driver.find_element(By.ID, "custom-message") 325 | 326 | # Clear the textarea 327 | custom_message_textarea.clear() 328 | 329 | # Check if the person has a name 330 | if person["name"]: 331 | # Send message with name 332 | custom_message_textarea.send_keys(MESSAGE_WITH_NAME.replace("{{name}}", person["name"])) 333 | else: 334 | # Send message without name 335 | custom_message_textarea.send_keys(MESSAGE_WITHOUT_NAME) 336 | 337 | # Get all buttons 338 | buttons = modal_action_bar.find_elements(By.TAG_NAME, "button") 339 | 340 | # Iterate over buttons 341 | for button in buttons: 342 | # Check if button contains the text "Senden" 343 | try: 344 | if CONTENT_OF_SEND_BUTTON in button.find_element(By.TAG_NAME, "span").text: 345 | # Click the button 346 | if ASK_BEFORE_SENDING: 347 | input(colored("[?] Press any key to send the connection request...", "magenta")) 348 | button.click() 349 | wait(0.7) 350 | print(colored(f"[+] Sent {person['name']} a connection request.", "green")) 351 | break 352 | except: 353 | continue 354 | else: 355 | print(colored("[!] Could not find the send button.", "red")) 356 | continue 357 | except Exception as err: 358 | print(colored(f"[*] Error: {err}", "red")) 359 | continue 360 | 361 | print(colored("\n[+] Done.", "green")) 362 | 363 | 364 | if __name__ == "__main__": 365 | main() 366 | -------------------------------------------------------------------------------- /src/utilities.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import json 4 | 5 | from termcolor import colored 6 | from datetime import datetime 7 | 8 | def get_query(argv): 9 | """ 10 | Checks if the user provided the search query through the arguments. 11 | """ 12 | if "--query" in argv: 13 | query_index = argv.index("--query") 14 | query = argv[query_index + 1] 15 | 16 | return str(query) 17 | else: 18 | return None 19 | 20 | def close_all_firefox_instances(): 21 | """ 22 | Closes all Firefox instances. 23 | """ 24 | print(colored("[*] Closing all Firefox instances...", "yellow")) 25 | 26 | if os.name == "nt": 27 | os.system("taskkill /im firefox.exe /f") 28 | elif os.name == "posix": 29 | os.system("killall firefox") 30 | else: 31 | print(colored("[!] Unsupported OS.", "red")) 32 | exit(1) 33 | 34 | def get_people_list_from_file(argv): 35 | """ 36 | Checks if the user has specified a file containing a list of people using the --people flag. 37 | If yes, returns the list of people. If no, ask for it. 38 | """ 39 | if "--people" in argv: 40 | people_index = argv.index("--people") 41 | people_file = argv[people_index + 1] 42 | 43 | # Check if file exists 44 | if not os.path.exists(people_file): 45 | print(colored("[!] File not found.", "red")) 46 | exit(1) 47 | 48 | # Check if file is a file 49 | if not os.path.isfile(people_file): 50 | print(colored("[!] Not a file.", "red")) 51 | exit(1) 52 | 53 | # Read file 54 | return json.loads(open(people_file, "r").read()) 55 | 56 | def scroll_to_bottom(driver): 57 | """ 58 | Scrolls to the bottom of the page. 59 | """ 60 | print(colored("[*] Scrolling to bottom of page...", "yellow")) 61 | driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") 62 | 63 | def print_ascii_art(): 64 | """ 65 | Prints out the ASCII Art logo. 66 | """ 67 | with open("assets/ascii_art.txt", "r") as f: 68 | ascii_art = f.read() 69 | print(colored(ascii_art, "cyan")) 70 | 71 | def get_n_search_results(argv): 72 | """ 73 | Checks if the user has specified the number of search results to scrape using the --n flag. 74 | If yes, returns the number of search results. If no, ask for it. 75 | """ 76 | if "--n" in argv: 77 | n_index = argv.index("--n") 78 | n = argv[n_index + 1] 79 | return int(n) 80 | else: 81 | print(colored("[*] Using default amount of search results: 30", "magenta")) 82 | return None 83 | 84 | def start_message(): 85 | """ 86 | Prints out the starting message. 87 | """ 88 | print(colored("[*] Initializing browser...", "yellow")) 89 | 90 | def get_firefox_profile_location(argv): 91 | """" 92 | Checks if the user has specified a Firefox profile location using the --profile flag. 93 | If yes, returns the location of the profile. If no, ask for it. 94 | """ 95 | if "--profile" in argv: 96 | profile_index = argv.index("--profile") 97 | profile_location = argv[profile_index + 1] 98 | return profile_location 99 | else: 100 | return input(colored("[?] Enter the location of your Firefox profile: ", "magenta")) 101 | 102 | def get_headless(argv): 103 | """ 104 | Checks if the user has specified the --headless flag. 105 | If yes, returns True. If no, returns False. 106 | """ 107 | if "--headless" in argv: 108 | return True 109 | else: 110 | return False 111 | 112 | def check_profile_location(path): 113 | """ 114 | Checks if the specified path is a directory. 115 | """ 116 | # Check if Firefox profile exists 117 | if not os.path.exists(path): 118 | print(colored("[!] Firefox profile not found.", "red")) 119 | exit(1) 120 | 121 | # Check if Firefox profile is a directory 122 | if not os.path.isdir(path): 123 | print(colored("[!] Firefox profile is not a directory.", "red")) 124 | exit(1) 125 | 126 | def wait(s): 127 | """ 128 | Waits for s seconds. 129 | """ 130 | time.sleep(s) 131 | 132 | def prepare_strucutre(): 133 | """ 134 | Prepares the output directory and the output file. 135 | """ 136 | # Create output directory 137 | if not os.path.exists("output"): 138 | os.mkdir("output") 139 | 140 | 141 | def save_to_json(data): 142 | """ 143 | Saves the data to a JSON file. 144 | """ 145 | now = datetime.now() 146 | timestamp = now.strftime("%d-%m-%Y_%H-%M-%S") 147 | filename = f"output/{timestamp}.json" 148 | 149 | with open(filename, "w") as f: 150 | f.write(json.dumps(data, indent=4)) 151 | 152 | print(colored(f"[+] Saved data to {filename}", "green")) 153 | --------------------------------------------------------------------------------