Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.venv/
.env
logs/
data/
sessions/
__pycache__/
*.pyc
*.pyo
*.pyd
*.pyw
*.pyz
*.pywz
*.pyzw
*.pyzwz
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 50 additions & 9 deletions env_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")

Expand Down
41 changes: 41 additions & 0 deletions languages/__init__.py
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions languages/english.py
Original file line number Diff line number Diff line change
@@ -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",
]
51 changes: 51 additions & 0 deletions languages/pt_br.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading