diff --git a/.env.example b/.env.example index 6b89246..34f26dc 100644 --- a/.env.example +++ b/.env.example @@ -2,11 +2,17 @@ # Copy this file to .env and fill in your real values: # cp .env.example .env -# LinkedIn credentials # LinkedIn credentials (required) LINKEDIN_USERNAME=your_email@example.com LINKEDIN_PASSWORD=your_password_here +# Language for selectors (required) +# Picks the LinkedIn UI strings/selectors module under languages/ (must match your LinkedIn UI language). +# Supported values: +# - english (default) | also accepts en +# - portuguese (brazil) | also accepts portuguese_brazil, pt-br, pt_br +LANGUAGE=english + # IMAP settings for automatic OTP retrieval (optional - VPS recommended) # When configured, the bot auto-reads verification codes from your email inbox. # Without these, you'll need to enter OTP codes manually (local) or via file exchange (VPS). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a4f210 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.venv/ +.env +logs/ +data/ +sessions/ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.pyw +*.pyz +*.pywz +*.pyzw +*.pyzwz \ No newline at end of file diff --git a/README.md b/README.md index 9c34ea8..3e4c205 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ cd linkedin-network-connection-bot # Set up credentials cp .env.example .env -# Edit .env with your LinkedIn email and password +# Edit .env with your LinkedIn email, password and language ``` ### 2. Run locally diff --git a/env_bootstrap.py b/env_bootstrap.py index b3a9410..e3d789c 100644 --- a/env_bootstrap.py +++ b/env_bootstrap.py @@ -6,6 +6,8 @@ import shutil import subprocess import sys +import tempfile +import urllib.request from pathlib import Path # All required packages - no external requirements.txt needed @@ -35,6 +37,51 @@ def _hash_packages(packages: list[str]) -> str: h.update("\n".join(sorted(packages)).encode("utf-8")) return h.hexdigest() +def _has_pip() -> bool: + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "--version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + except subprocess.CalledProcessError: + return False + except Exception: + return False + +def _bootstrap_pip() -> None: + try: + subprocess.check_call( + [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return + except Exception: + pass + + tmp_path = None + try: + url = "https://bootstrap.pypa.io/get-pip.py" + with urllib.request.urlopen(url) as response: + get_pip = response.read() + with tempfile.NamedTemporaryFile("wb", delete=False, suffix="-get-pip.py") as tmp_file: + tmp_file.write(get_pip) + tmp_path = tmp_file.name + subprocess.check_call([sys.executable, tmp_path]) + except Exception as exc: + raise RuntimeError( + "Failed to bootstrap pip. Ensure your Python installation includes ensurepip or has network access to download get-pip.py." + ) from exc + finally: + if tmp_path is not None and os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + + def ensure_venv( venv_name: str = ".venv", flag_filename: str = ".deps_ok", @@ -70,16 +117,10 @@ def ensure_venv( flag_ok = False if not flag_ok: - try: - subprocess.check_call([sys.executable, "-m", "pip", "--version"]) - except subprocess.CalledProcessError: - pass - if shutil.which("pip") is None: - try: - subprocess.check_call([sys.executable, "-m", "ensurepip", "--upgrade"]) - except Exception: - pass + if not _has_pip(): + _bootstrap_pip() + subprocess.check_call([sys.executable, "-m", "pip", "install", "--upgrade", "pip"]) subprocess.check_call([sys.executable, "-m", "pip", "install", *PACKAGES]) flag_path.write_text(current_hash, encoding="utf-8") diff --git a/languages/__init__.py b/languages/__init__.py new file mode 100644 index 0000000..fb39c11 --- /dev/null +++ b/languages/__init__.py @@ -0,0 +1,41 @@ +"""LinkedIn UI selectors per language (see LANGUAGE in .env).""" +import importlib +import os +import re + + +def normalize_language_code(raw): + if raw is None or not str(raw).strip(): + return "english" + v = str(raw).strip().lower().strip('"').strip("'") + v = v.replace("-", "_") + v = re.sub(r"[\s()]+", "_", v) + v = re.sub(r"_+", "_", v).strip("_") + + aliases = { + "en": "english", + "english": "english", + "pt_br": "pt_br", + "pt-br": "pt_br", + "portuguese_brazil": "pt_br", + "portuguese (brazil)": "pt_br", + } + return aliases.get(v, v) + + +_strings_cache = None + + +def get_strings(): + """Load language module once (after dotenv).""" + global _strings_cache + if _strings_cache is not None: + return _strings_cache + key = normalize_language_code(os.getenv("LANGUAGE", "english")) + try: + _strings_cache = importlib.import_module(f"languages.{key}") + print(f"[LANG] Using UI language pack: {key}") + except ImportError: + print(f"[LANG] Unknown LANGUAGE={key!r}, falling back to english") + _strings_cache = importlib.import_module("languages.english") + return _strings_cache diff --git a/languages/english.py b/languages/english.py new file mode 100644 index 0000000..65e03ed --- /dev/null +++ b/languages/english.py @@ -0,0 +1,42 @@ +"""LinkedIn UI selectors — English (LANGUAGE=english or en).""" +import re + +CONNECT_BUTTON_LOG_LABEL = "Connect" + +CONNECT_RESULT_BUTTONS_XPATH = ( + "//a[contains(@href, 'search-custom-invite')] | " + "//button[contains(@aria-label, 'Invite')][contains(@aria-label, 'to connect')] | " + "//a[contains(@aria-label, 'Invite')][contains(@aria-label, 'to connect')] | " + "//button[normalize-space(.)='Connect'] | " + "//a[normalize-space(.)='Connect']" +) + +CONNECT_NAME_REGEXES = ( + re.compile(r"Invite\s+(.+?)\s+to connect"), +) + +MODAL_ADD_NOTE_CSS = 'button[aria-label="Add a note"]' +MODAL_SEND_WITHOUT_NOTE_CSS = 'button[aria-label="Send without a note"]' +MODAL_SEND_INVITE_CSS = 'button[aria-label="Send invitation"]' + +MODAL_ADD_NOTE_DOM_XPATH = ( + "//div[contains(@class, 'send-invite')]//button[@aria-label='Add a note']" +) + +SEND_INVITE_CLICKABLE_XPATH = ( + "//button[@aria-label='Send invitation'] | //button[normalize-space(.)='Send']" +) + +PENDING_STATUS_XPATH = ( + "//button[contains(@aria-label, 'Pending')] | //span[contains(text(), 'Pending')]" +) + +DISMISS_MODAL_XPATH = ( + "//button[@aria-label='Dismiss'] | //button[contains(@class, 'dismiss')]" +) + +PAGINATION_NEXT_BUTTON_XPATHS = [ + "//button[@data-testid='pagination-controls-next-button-visible']", + "//button[normalize-space(.)='Next']", + "//button[contains(@class, 'pagination')]//span[text()='Next']/ancestor::button", +] diff --git a/languages/pt_br.py b/languages/pt_br.py new file mode 100644 index 0000000..aa22729 --- /dev/null +++ b/languages/pt_br.py @@ -0,0 +1,51 @@ +"""LinkedIn UI selectors — Portuguese (Brazil): LANGUAGE=pt-br, pt_br, or portuguese (brazil).""" +import re + +CONNECT_BUTTON_LOG_LABEL = "Conectar" + +CONNECT_RESULT_BUTTONS_XPATH = ( + "//a[contains(@href, 'search-custom-invite')] | " + "//button[contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'convidar')]" + "[contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'conectar')] | " + "//a[contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'convidar')]" + "[contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'conectar')] | " + "//button[normalize-space(.)='Conectar'] | " + "//a[normalize-space(.)='Conectar']" +) + +CONNECT_NAME_REGEXES = ( + re.compile(r"Convidar\s+(.+?)\s+para\s+se\s+conectar", re.IGNORECASE), +) + +MODAL_ADD_NOTE_CSS = ( + 'button[aria-label="Adicionar uma nota"], ' + 'button[aria-label="Adicionar nota"]' +) +MODAL_SEND_WITHOUT_NOTE_CSS = ( + 'button[aria-label="Enviar sem uma nota"], ' + 'button[aria-label="Enviar sem nota"]' +) +MODAL_SEND_INVITE_CSS = 'button[aria-label="Enviar convite"]' + +MODAL_ADD_NOTE_DOM_XPATH = ( + "//div[contains(@class, 'send-invite')]//button[" + "@aria-label='Adicionar uma nota' or @aria-label='Adicionar nota']" +) + +SEND_INVITE_CLICKABLE_XPATH = ( + "//button[@aria-label='Enviar convite'] | //button[normalize-space(.)='Enviar']" +) + +PENDING_STATUS_XPATH = ( + "//button[contains(@aria-label, 'Pendente')] | //span[contains(text(), 'Pendente')]" +) + +DISMISS_MODAL_XPATH = ( + "//button[@aria-label='Dispensar'] | //button[contains(@class, 'dismiss')]" +) + +PAGINATION_NEXT_BUTTON_XPATHS = [ + "//button[@data-testid='pagination-controls-next-button-visible']", + "//button[normalize-space(.)='Próximo']", + "//button[contains(@class, 'pagination')]//span[text()='Próximo']/ancestor::button", +] diff --git a/run.py b/run.py index 9c81d67..4e6910d 100644 --- a/run.py +++ b/run.py @@ -17,6 +17,22 @@ # Load .env file (credentials, etc.) load_dotenv() +from languages import get_strings + +STRINGS = get_strings() + + +def first_name_from_connect_aria(aria_label): + """Extract first name from a people-search Connect button aria-label (active LANGUAGE pack).""" + if not aria_label: + return None + for rx in STRINGS.CONNECT_NAME_REGEXES: + m = rx.search(aria_label) + if m: + return m.group(1).split()[0] + return None + + # Selenium Imports from selenium import webdriver from selenium.webdriver.chrome.options import Options @@ -413,7 +429,10 @@ def load_metrics(self): } def save_metrics(self): - with open(self.metrics_file, 'w') as f: + parent = os.path.dirname(os.path.abspath(self.metrics_file)) + if parent: + os.makedirs(parent, exist_ok=True) + with open(self.metrics_file, 'w', encoding='utf-8') as f: json.dump(self.metrics, f, indent=4) def get_daily_count(self): @@ -574,9 +593,21 @@ def run_group_sets_mode(self): break def should_run_group(self, group, today_name): - run_day = (group.get("run_day") or "").strip().lower() - if run_day and run_day != today_name: + # Obtém o valor do run_day (pode ser string ou lista) + run_day_config = group.get("run_day") + + # Normaliza para uma lista de dias em minúsculo + if isinstance(run_day_config, str): + days_to_run = [run_day_config.strip().lower()] + elif isinstance(run_day_config, list): + days_to_run = [str(d).strip().lower() for d in run_day_config] + else: + days_to_run = [] + + # Verifica se hoje é um dos dias permitidos + if days_to_run and today_name not in days_to_run: return False + completed_runs = self.metrics.get_group_runs(self.active_group_set_id) if not completed_runs: return True @@ -654,11 +685,7 @@ def go_to_next_page(self): try: # Look for the Next button using multiple selectors next_btn = None - selectors = [ - "//button[@data-testid='pagination-controls-next-button-visible']", - "//button[normalize-space(.)='Next']", - "//button[contains(@class, 'pagination')]//span[text()='Next']/ancestor::button" - ] + selectors = list(STRINGS.PAGINATION_NEXT_BUTTON_XPATHS) for selector in selectors: try: @@ -695,10 +722,7 @@ def go_to_next_page(self): def count_connect_buttons(self): """Count the number of Connect buttons/links on the current page.""" try: - connect_elements = self.driver.find_elements(By.XPATH, - "//a[contains(@href, 'search-custom-invite')] | " - "//button[contains(@aria-label, 'Invite')][contains(@aria-label, 'to connect')]" - ) + connect_elements = self.driver.find_elements(By.XPATH, STRINGS.CONNECT_RESULT_BUTTONS_XPATH) return len(connect_elements) except: return 0 @@ -757,17 +781,12 @@ def _process_single_page(self, max_connections): time.sleep(2) print_lg("[SCROLL] Done scrolling.") - # Updated XPath to include 'a' (anchor) tags as LinkedIn frequently renders these as links - # We look for aria-labels like "Invite {Name} to connect" or inner text "Connect" - xpath_query = """ - //button[contains(@aria-label, 'Invite')][contains(@aria-label, 'to connect')] | - //a[contains(@aria-label, 'Invite')][contains(@aria-label, 'to connect')] | - //button[normalize-space(.)='Connect'] | - //a[normalize-space(.)='Connect'] - """ - + xpath_query = STRINGS.CONNECT_RESULT_BUTTONS_XPATH + buttons = self.driver.find_elements(By.XPATH, xpath_query) - print_lg(f"[BUTTONS] Found {len(buttons)} 'Connect' buttons/links on this page.") + print_lg( + f"[BUTTONS] Found {len(buttons)} '{STRINGS.CONNECT_BUTTON_LOG_LABEL}' buttons/links on this page." + ) # Debug: Log first few button details for idx, btn in enumerate(buttons[:3]): @@ -917,6 +936,7 @@ def _process_single_page(self, max_connections): processed.add(button_key) continue + """ # Check 2b: Skip non-AI roles (recruiters, HR, sales, etc.) non_ai_roles = [ 'recruiter', 'talent', 'hr ', 'human resources', 'headhunter', @@ -933,7 +953,8 @@ def _process_single_page(self, max_connections): print_lg(f"[VERIFY] SKIP - non-AI role detected: {card_text[:100]}...") if button_key: processed.add(button_key) - continue + continue + """ # Check 2c: AI keyword verification (only for AI group set) if self.active_group_set_id == 'ai': @@ -975,16 +996,17 @@ def _process_single_page(self, max_connections): # Extract Name first_name = "there" try: - aria_label = button.get_attribute("aria-label") - if aria_label and "Invite" in aria_label and "to connect" in aria_label: - full_name = aria_label.split("Invite ")[1].split(" to connect")[0] - first_name = full_name.split(' ')[0] + aria_label = button.get_attribute("aria-label") + extracted = first_name_from_connect_aria(aria_label) + if extracted: + first_name = extracted else: # Fallback: Use card text (first line is usually the name) if card_text: full_name = card_text.split('\n')[0] first_name = full_name.split(' ')[0] - if len(first_name) < 2: first_name = "there" + if len(first_name) < 2: + first_name = "there" except Exception as e: print_lg(f"Name extraction warning: {e}") @@ -1112,19 +1134,17 @@ def handle_connection_modal(self, name, connect_element=None): print_lg(f"[MODAL] Modal found in Shadow DOM!") time.sleep(0.5) # Let content load - # Find buttons in shadow DOM + # Find buttons in shadow DOM (EN + PT-BR labels) try: - add_note_btn = shadow_root.find_element(By.CSS_SELECTOR, - 'button[aria-label="Add a note"]' - ) - except: + add_note_btn = shadow_root.find_element(By.CSS_SELECTOR, STRINGS.MODAL_ADD_NOTE_CSS) + except Exception: pass - + try: - send_without_note_btn = shadow_root.find_element(By.CSS_SELECTOR, - 'button[aria-label="Send without a note"]' + send_without_note_btn = shadow_root.find_element( + By.CSS_SELECTOR, STRINGS.MODAL_SEND_WITHOUT_NOTE_CSS ) - except: + except Exception: pass if add_note_btn or send_without_note_btn: @@ -1135,14 +1155,12 @@ def handle_connection_modal(self, name, connect_element=None): # Also try regular DOM (in case LinkedIn changes behavior) try: - modal = self.driver.find_element(By.XPATH, - "//div[contains(@class, 'send-invite')]//button[@aria-label='Add a note']" - ) + modal = self.driver.find_element(By.XPATH, STRINGS.MODAL_ADD_NOTE_DOM_XPATH) if modal: add_note_btn = modal print_lg(f"[MODAL] Modal found in regular DOM!") break - except: + except Exception: pass # If no modal found, check for Pending status @@ -1151,14 +1169,11 @@ def handle_connection_modal(self, name, connect_element=None): time.sleep(1) try: - pending = self.driver.find_elements(By.XPATH, - f"//button[contains(@aria-label, 'Pending')] | " - f"//span[contains(text(), 'Pending')]" - ) + pending = self.driver.find_elements(By.XPATH, STRINGS.PENDING_STATUS_XPATH) if pending: print_lg(f"[DIRECT SEND] Connection to {name} shows Pending status.") return True - except: + except Exception: pass print_lg(f"[WARNING] Could not confirm connection status for {name}.") @@ -1218,20 +1233,19 @@ def handle_connection_modal(self, name, connect_element=None): send_btn = None if shadow_root: try: - send_btn = shadow_root.find_element(By.CSS_SELECTOR, - 'button[aria-label="Send invitation"], button.artdeco-button--primary' + send_btn = shadow_root.find_element( + By.CSS_SELECTOR, + f'{STRINGS.MODAL_SEND_INVITE_CSS}, button.artdeco-button--primary', ) - except: + except Exception: pass - + if not send_btn: try: send_btn = WebDriverWait(self.driver, 3).until( - EC.element_to_be_clickable((By.XPATH, - "//button[@aria-label='Send invitation'] | //button[normalize-space(.)='Send']" - )) + EC.element_to_be_clickable((By.XPATH, STRINGS.SEND_INVITE_CLICKABLE_XPATH)) ) - except: + except Exception: pass if send_btn: @@ -1261,22 +1275,19 @@ def handle_connection_modal(self, name, connect_element=None): print_lg("[FALLBACK] Trying shadow DOM one more time...") shadow_root = self.get_shadow_root_modal() if shadow_root: - send_btn = shadow_root.find_element(By.CSS_SELECTOR, - 'button[aria-label="Send without a note"]' - ) + send_btn = shadow_root.find_element(By.CSS_SELECTOR, STRINGS.MODAL_SEND_WITHOUT_NOTE_CSS) send_btn.click() print_lg(f"[SUCCESS] Sent invite to {name} (without note - fallback).") return True except Exception as e: print_lg(f"Fallback failed: {e}") - pass - + # --- CLEANUP (CLOSE MODAL) --- print_lg(f"Could not find Send buttons for {name}. Closing modal.") try: - close_btn = self.driver.find_element(By.XPATH, "//button[@aria-label='Dismiss'] | //button[contains(@class, 'dismiss')]") + close_btn = self.driver.find_element(By.XPATH, STRINGS.DISMISS_MODAL_XPATH) close_btn.click() - except: + except Exception: pass return False @@ -1290,7 +1301,7 @@ def handle_connection_modal(self, name, connect_element=None): if not username or not password: print("ERROR: LinkedIn credentials not found!") print(" 1. Copy .env.example to .env: cp .env.example .env") - print(" 2. Edit .env with your LinkedIn email and password") + print(" 2. Edit .env with your LinkedIn email, password and language") sys.exit(1) driver, wait, actions = setup_browser() diff --git a/search_config.json b/search_config.json index 9e34f01..44dce47 100644 --- a/search_config.json +++ b/search_config.json @@ -1,10 +1,9 @@ { "version": "1.0", - "mode": "keywords", + "mode": "group_sets", "filters": { "skip_profiles_keywords": [ - "example-company", - "spam-keyword" + "example-company" ] }, "limits": { @@ -13,8 +12,9 @@ }, "keywords_config": { "keywords": [ - "ai engineer", - "machine learning engineer", + "genai recruiter", + "it hunter global", + "talent acquisition latam", "genai engineer" ] }, @@ -63,10 +63,59 @@ "page_load_wait_seconds": 10 }, "group_sets_config": { - "active_set": "ai", + "active_set": "genai-v1", "invites_per_keyword": 5, "keywords_per_group": 4, "sets": { + "genai-v1": { + "name": "GenAI Recruiters, Specialists, Heads and Founders", + "groups": [ + { + "id": "genai_recruiters", + "name": "GenAI Recruiters", + "run_day": ["Monday", "Wednesday", "Friday"], + "keywords": [ + "genai recruiter", + "genai hunter global", + "genai talent acquisition latam" + ] + }, + { + "id": "genai_specialists", + "name": "GenAI & LLM Specialists", + "run_day": ["Tuesday", "Thursday"], + "keywords": [ + "senior genai engineer", + "senior llm engineer", + "senior generative ai engineer", + "generative ai specialist", + "llm specialist" + ] + }, + { + "id": "genai_heads", + "name": "GenAI Heads and Managers", + "run_day": ["Saturday"], + "keywords": [ + "head of genai", + "genai director", + "chief genai officer", + "vp genai" + ] + }, + { + "id": "genai_founders", + "name": "GenAI Founders and Startups", + "run_day": "Sunday", + "keywords": [ + "genai startup founder", + "genai company founder", + "genai entrepreneur", + "generative ai founder" + ] + } + ] + }, "ecommerce-mvp": { "name": "E-commerce MVP", "groups": [