linkedin-agent-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- linkedin_agent_cli-0.1.0.dist-info/METADATA +197 -0
- linkedin_agent_cli-0.1.0.dist-info/RECORD +34 -0
- linkedin_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- linkedin_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- linkedin_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- linkedin_cli/__init__.py +9 -0
- linkedin_cli/actions/__init__.py +0 -0
- linkedin_cli/actions/connect.py +118 -0
- linkedin_cli/actions/conversations.py +132 -0
- linkedin_cli/actions/message.py +153 -0
- linkedin_cli/actions/profile.py +22 -0
- linkedin_cli/actions/search.py +186 -0
- linkedin_cli/actions/status.py +112 -0
- linkedin_cli/api/__init__.py +0 -0
- linkedin_cli/api/client.py +182 -0
- linkedin_cli/api/messaging/__init__.py +11 -0
- linkedin_cli/api/messaging/conversations.py +56 -0
- linkedin_cli/api/messaging/send.py +74 -0
- linkedin_cli/api/messaging/utils.py +24 -0
- linkedin_cli/api/voyager.py +319 -0
- linkedin_cli/auth.py +98 -0
- linkedin_cli/browser/__init__.py +0 -0
- linkedin_cli/browser/login.py +140 -0
- linkedin_cli/browser/nav.py +115 -0
- linkedin_cli/cli.py +396 -0
- linkedin_cli/conf.py +33 -0
- linkedin_cli/enums.py +11 -0
- linkedin_cli/exceptions.py +47 -0
- linkedin_cli/launcher.py +60 -0
- linkedin_cli/page_state.py +148 -0
- linkedin_cli/session.py +169 -0
- linkedin_cli/setup/__init__.py +0 -0
- linkedin_cli/setup/self_profile.py +25 -0
- linkedin_cli/url_utils.py +30 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# linkedin/actions/message.py
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
|
|
5
|
+
from playwright.sync_api import Error as PlaywrightError, Locator
|
|
6
|
+
from linkedin_cli.browser.nav import goto_page, human_type, dump_page_html
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
LINKEDIN_MESSAGING_URL = "https://www.linkedin.com/messaging/thread/new/"
|
|
11
|
+
|
|
12
|
+
# Selector fallback chains: semantic/ARIA first, then class-based.
|
|
13
|
+
# LinkedIn A/B tests UI variants per account and renames classes often.
|
|
14
|
+
# Each key maps to a list tried in order; first with a match wins.
|
|
15
|
+
SELECTOR_CHAINS = {
|
|
16
|
+
# ── New thread: recipient search ──
|
|
17
|
+
"connections_input": [
|
|
18
|
+
'input[role="combobox"][placeholder*="name"]',
|
|
19
|
+
'input[class*="msg-connections"]',
|
|
20
|
+
'input[placeholder*="Type a name"]',
|
|
21
|
+
'input[type="text"][aria-owns]',
|
|
22
|
+
],
|
|
23
|
+
"search_result_row": [
|
|
24
|
+
'ul[role="listbox"] li[role="option"]',
|
|
25
|
+
'div[class*="msg-connections-typeahead__search-result-row"]',
|
|
26
|
+
'li[class*="search-result"]',
|
|
27
|
+
],
|
|
28
|
+
# ── Thread: compose area ──
|
|
29
|
+
"compose_input": [
|
|
30
|
+
'div[role="textbox"][aria-label*="Write a message"]',
|
|
31
|
+
'div[role="textbox"][aria-label*="message"i]',
|
|
32
|
+
'div[class*="msg-form__contenteditable"]',
|
|
33
|
+
'div[contenteditable="true"]',
|
|
34
|
+
],
|
|
35
|
+
"compose_send": [
|
|
36
|
+
'button[type="submit"][class*="msg-form"]',
|
|
37
|
+
'button[class*="send-btn"]',
|
|
38
|
+
'button[class*="send-button"]',
|
|
39
|
+
'form button[type="submit"]',
|
|
40
|
+
'button[type="submit"]',
|
|
41
|
+
],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find(page, key: str, timeout: int = 5000) -> Locator:
|
|
46
|
+
"""Try each selector in the chain for *key*, return the first with matches.
|
|
47
|
+
|
|
48
|
+
Raises PlaywrightError if none match within *timeout* ms.
|
|
49
|
+
"""
|
|
50
|
+
chain = SELECTOR_CHAINS[key]
|
|
51
|
+
for sel in chain:
|
|
52
|
+
loc = page.locator(sel)
|
|
53
|
+
try:
|
|
54
|
+
loc.first.wait_for(state="attached", timeout=timeout)
|
|
55
|
+
logger.debug("Selector hit for %s: %s", key, sel)
|
|
56
|
+
return loc
|
|
57
|
+
except (PlaywrightError, TimeoutError):
|
|
58
|
+
continue
|
|
59
|
+
tried = ", ".join(chain)
|
|
60
|
+
raise PlaywrightError(f"No selector matched for '{key}'. Tried: {tried}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── Public entry point ────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def send_raw_message(session, profile: Dict[str, Any], message: str) -> bool:
|
|
67
|
+
"""Send an arbitrary message to a profile. Returns True if sent."""
|
|
68
|
+
public_identifier = profile.get("public_identifier")
|
|
69
|
+
|
|
70
|
+
if _send_message(session, profile, message):
|
|
71
|
+
return True
|
|
72
|
+
dump_page_html(session, profile, category="message_direct")
|
|
73
|
+
|
|
74
|
+
if _send_message_via_api(session, profile, message):
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
logger.error("All send methods failed for %s", public_identifier)
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _send_message(session, profile: Dict[str, Any], message: str) -> bool:
|
|
82
|
+
"""Navigate to /messaging/thread/new/?recipient=<urn>, compose, send.
|
|
83
|
+
|
|
84
|
+
Uses the target URN (promoted to its own Lead column in crm.0005) to
|
|
85
|
+
skip the search-by-name step entirely. Post-migration 0007 the Lead
|
|
86
|
+
row no longer carries first_name/last_name, so name-based search is
|
|
87
|
+
not available anyway.
|
|
88
|
+
"""
|
|
89
|
+
from linkedin_cli.api.messaging.utils import encode_urn
|
|
90
|
+
|
|
91
|
+
public_identifier = profile.get("public_identifier")
|
|
92
|
+
target_urn = profile.get("urn")
|
|
93
|
+
if not target_urn:
|
|
94
|
+
logger.error("Cannot send via direct thread: no URN for %s", public_identifier)
|
|
95
|
+
return False
|
|
96
|
+
try:
|
|
97
|
+
thread_url = f"{LINKEDIN_MESSAGING_URL}?recipient={encode_urn(target_urn)}"
|
|
98
|
+
goto_page(
|
|
99
|
+
session,
|
|
100
|
+
action=lambda: session.page.goto(thread_url),
|
|
101
|
+
expected_url_pattern="/messaging",
|
|
102
|
+
timeout=30_000,
|
|
103
|
+
error_message="Error opening messaging thread",
|
|
104
|
+
)
|
|
105
|
+
session.wait(1, 2)
|
|
106
|
+
|
|
107
|
+
human_type(
|
|
108
|
+
_find(session.page, "compose_input").first,
|
|
109
|
+
message,
|
|
110
|
+
min_delay=10,
|
|
111
|
+
max_delay=50,
|
|
112
|
+
)
|
|
113
|
+
_find(session.page, "compose_send").first.click(delay=200)
|
|
114
|
+
session.wait(0.5, 1)
|
|
115
|
+
logger.info("Message sent to %s (direct thread)", public_identifier)
|
|
116
|
+
return True
|
|
117
|
+
except (PlaywrightError, TimeoutError) as e:
|
|
118
|
+
logger.error("Failed to send message to %s (direct thread) → %s", public_identifier, e)
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _send_message_via_api(session, profile: Dict[str, Any], message: str) -> bool:
|
|
123
|
+
"""Last-resort fallback: send via Voyager Messaging API.
|
|
124
|
+
|
|
125
|
+
Requires profile dict to contain 'urn' (target profile URN).
|
|
126
|
+
"""
|
|
127
|
+
from linkedin_cli.api.client import PlaywrightLinkedinAPI
|
|
128
|
+
from linkedin_cli.api.messaging import send_message
|
|
129
|
+
from linkedin_cli.actions.conversations import find_conversation_urn, find_conversation_urn_via_navigation
|
|
130
|
+
|
|
131
|
+
public_identifier = profile.get("public_identifier")
|
|
132
|
+
target_urn = profile.get("urn")
|
|
133
|
+
if not target_urn:
|
|
134
|
+
logger.error("API send failed for %s → no URN in profile dict", public_identifier)
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
mailbox_urn = session.self_profile["urn"]
|
|
138
|
+
api = PlaywrightLinkedinAPI(session=session)
|
|
139
|
+
|
|
140
|
+
conversation_urn = find_conversation_urn(api, target_urn, mailbox_urn)
|
|
141
|
+
if not conversation_urn:
|
|
142
|
+
conversation_urn = find_conversation_urn_via_navigation(session, target_urn)
|
|
143
|
+
if not conversation_urn:
|
|
144
|
+
logger.error("API send failed for %s → no conversation found", public_identifier)
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
send_message(api, conversation_urn, message, mailbox_urn)
|
|
149
|
+
logger.info("Message sent to %s (API fallback)", public_identifier)
|
|
150
|
+
return True
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error("API send failed for %s → %s", public_identifier, e)
|
|
153
|
+
return False
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# linkedin/actions/profile.py
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from ..api.client import PlaywrightLinkedinAPI
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def scrape_profile(session, profile: dict):
|
|
10
|
+
url = profile["url"]
|
|
11
|
+
|
|
12
|
+
session.ensure_browser()
|
|
13
|
+
session.wait()
|
|
14
|
+
|
|
15
|
+
api = PlaywrightLinkedinAPI(session=session)
|
|
16
|
+
|
|
17
|
+
logger.info("Enriching profile → %s", url)
|
|
18
|
+
profile, data = api.get_profile(profile_url=url)
|
|
19
|
+
|
|
20
|
+
logger.info("Profile enriched – %s", profile.get("public_identifier")) if profile else None
|
|
21
|
+
|
|
22
|
+
return profile, data
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
from urllib.parse import urlparse, parse_qs, urlencode
|
|
5
|
+
|
|
6
|
+
from linkedin_cli.browser.nav import goto_page, extract_in_urls
|
|
7
|
+
|
|
8
|
+
# LinkedIn connection-degree filter codes for People search (`network` facet).
|
|
9
|
+
NETWORK_CODES = {"first": "F", "second": "S", "third": "O"}
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
SELECTORS = {
|
|
14
|
+
"search_bar": "//input[contains(@placeholder, 'Search')]",
|
|
15
|
+
"profile_links": 'a[href*="/in/"]',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _go_to_profile(session: "LinkedInSession", url: str, public_identifier: str):
|
|
20
|
+
if f"/in/{public_identifier}" in session.page.url:
|
|
21
|
+
return
|
|
22
|
+
logger.debug("Direct navigation → %s", public_identifier)
|
|
23
|
+
try:
|
|
24
|
+
goto_page(
|
|
25
|
+
session,
|
|
26
|
+
action=lambda: session.page.goto(url, wait_until="domcontentloaded"),
|
|
27
|
+
expected_url_pattern=f"/in/{public_identifier}",
|
|
28
|
+
error_message="Failed to navigate to the target profile"
|
|
29
|
+
)
|
|
30
|
+
except RuntimeError:
|
|
31
|
+
# Redirect to a different /in/ slug is tolerated; reconciling the
|
|
32
|
+
# lead's stored slug is the caller's job (this layer holds no DB).
|
|
33
|
+
if not _detect_profile_redirect(session, public_identifier):
|
|
34
|
+
raise
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _detect_profile_redirect(session, old_public_id: str) -> str | None:
|
|
38
|
+
"""Return the new public_id if LinkedIn redirected to a different /in/ slug."""
|
|
39
|
+
from urllib.parse import unquote
|
|
40
|
+
from linkedin_cli.url_utils import url_to_public_id
|
|
41
|
+
|
|
42
|
+
new_id = url_to_public_id(unquote(session.page.url))
|
|
43
|
+
if new_id and new_id != old_public_id:
|
|
44
|
+
logger.info("Profile redirect: %s → %s", old_public_id, new_id)
|
|
45
|
+
return new_id
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def visit_profile(session: "LinkedInSession", profile: Dict[str, Any]):
|
|
50
|
+
public_identifier = profile.get("public_identifier")
|
|
51
|
+
|
|
52
|
+
# Ensure browser is alive before doing anything
|
|
53
|
+
session.ensure_browser()
|
|
54
|
+
|
|
55
|
+
already_there = f"/in/{public_identifier}" in session.page.url
|
|
56
|
+
|
|
57
|
+
if already_there:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
url = profile.get("url")
|
|
61
|
+
_go_to_profile(session, url, public_identifier)
|
|
62
|
+
|
|
63
|
+
# Emit the /in/ profile URLs visible on the page; enrichment is caller-side.
|
|
64
|
+
return extract_in_urls(session.page)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _search_url(keyword: str, page: int = 1, network=None) -> str:
|
|
68
|
+
"""Build a People-search results URL, optionally filtered by connection degree.
|
|
69
|
+
|
|
70
|
+
*network* is an optional list of degree codes — ``F`` (1st), ``S`` (2nd),
|
|
71
|
+
``O`` (3rd+) — passed to LinkedIn's ``network`` facet as a JSON array.
|
|
72
|
+
"""
|
|
73
|
+
params = {"keywords": keyword, "origin": "FACETED_SEARCH"}
|
|
74
|
+
if network:
|
|
75
|
+
params["network"] = json.dumps(list(network))
|
|
76
|
+
if page > 1:
|
|
77
|
+
params["page"] = page
|
|
78
|
+
return "https://www.linkedin.com/search/results/people/?" + urlencode(params)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _initiate_search(session: "LinkedInSession", keyword: str):
|
|
82
|
+
"""Navigate directly to LinkedIn People search results for *keyword*."""
|
|
83
|
+
goto_page(
|
|
84
|
+
session,
|
|
85
|
+
action=lambda: session.page.goto(_search_url(keyword)),
|
|
86
|
+
expected_url_pattern="/search/results/people/",
|
|
87
|
+
error_message="Failed to reach People search results",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _paginate_to_next_page(session: "LinkedInSession", page_num: int):
|
|
92
|
+
page = session.page
|
|
93
|
+
current = urlparse(page.url)
|
|
94
|
+
params = parse_qs(current.query)
|
|
95
|
+
params["page"] = [str(page_num)]
|
|
96
|
+
new_url = current._replace(query=urlencode(params, doseq=True)).geturl()
|
|
97
|
+
|
|
98
|
+
logger.debug("Scanning search page %s", page_num)
|
|
99
|
+
goto_page(
|
|
100
|
+
session,
|
|
101
|
+
action=lambda: page.goto(new_url),
|
|
102
|
+
expected_url_pattern="/search/results/",
|
|
103
|
+
error_message="Pagination failed"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def search_people(session: "LinkedInSession", keyword: str, page: int = 1, network=None) -> dict:
|
|
108
|
+
"""Search LinkedIn People; return the result page as a structured envelope.
|
|
109
|
+
|
|
110
|
+
*network* optionally filters by connection degree (a list of `F`/`S`/`O`
|
|
111
|
+
codes). Results carry only ``{public_identifier, url}`` — no `urn`; a
|
|
112
|
+
follow-up `profile` scrape per url resolves the rest. Returns::
|
|
113
|
+
|
|
114
|
+
{"query": ..., "page": ..., "network": [...]|None,
|
|
115
|
+
"profiles": [{"public_identifier": ..., "url": ...}, ...]}
|
|
116
|
+
"""
|
|
117
|
+
from linkedin_cli.url_utils import url_to_public_id
|
|
118
|
+
|
|
119
|
+
session.ensure_browser()
|
|
120
|
+
goto_page(
|
|
121
|
+
session,
|
|
122
|
+
action=lambda: session.page.goto(_search_url(keyword, page, network)),
|
|
123
|
+
expected_url_pattern="/search/results/people/",
|
|
124
|
+
error_message="Failed to reach People search results",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
profiles, seen = [], set()
|
|
128
|
+
for url in extract_in_urls(session.page):
|
|
129
|
+
public_id = url_to_public_id(url)
|
|
130
|
+
if public_id and public_id not in seen:
|
|
131
|
+
seen.add(public_id)
|
|
132
|
+
profiles.append({"public_identifier": public_id, "url": url})
|
|
133
|
+
|
|
134
|
+
return {"query": keyword, "page": page,
|
|
135
|
+
"network": list(network) if network else None, "profiles": profiles}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _simulate_human_search(session: "LinkedInSession", profile: Dict[str, Any]) -> bool:
|
|
139
|
+
full_name = profile.get("full_name")
|
|
140
|
+
public_identifier = profile.get("public_identifier")
|
|
141
|
+
|
|
142
|
+
# Reconstruct full_name if it's missing
|
|
143
|
+
if not full_name:
|
|
144
|
+
first = profile.get("first_name", "").strip()
|
|
145
|
+
last = profile.get("last_name", "").strip()
|
|
146
|
+
if first or last:
|
|
147
|
+
full_name = f"{first} {last}".strip() if first and last else (first or last)
|
|
148
|
+
else:
|
|
149
|
+
logger.error(f"No name available for {public_identifier}")
|
|
150
|
+
logger.debug(profile)
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
if not public_identifier:
|
|
154
|
+
logger.error(f"Missing public_identifier for '{full_name}'")
|
|
155
|
+
raise ValueError("public_identifier is required")
|
|
156
|
+
|
|
157
|
+
logger.info(f"Human search → '{full_name}' (target: {public_identifier})")
|
|
158
|
+
|
|
159
|
+
_initiate_search(session, full_name)
|
|
160
|
+
|
|
161
|
+
max_pages_to_scan = 1
|
|
162
|
+
|
|
163
|
+
for current_page in range(1, max_pages_to_scan + 1):
|
|
164
|
+
logger.info("Scanning search results page %s", current_page)
|
|
165
|
+
|
|
166
|
+
target_locator = None
|
|
167
|
+
for link in session.page.locator(SELECTORS["profile_links"]).all():
|
|
168
|
+
href = link.get_attribute("href") or ""
|
|
169
|
+
if f"/in/{public_identifier}" in href:
|
|
170
|
+
target_locator = link
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
if target_locator:
|
|
174
|
+
logger.info("Target found in results → clicking")
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
if session.page.get_by_text("No results found", exact=False).count() > 0:
|
|
178
|
+
logger.info("No results found → stopping search")
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
if current_page < max_pages_to_scan:
|
|
182
|
+
_paginate_to_next_page(session, current_page + 1)
|
|
183
|
+
session.wait()
|
|
184
|
+
|
|
185
|
+
logger.info("Target %s not found → falling back to direct URL", public_identifier)
|
|
186
|
+
return False
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# linkedin/actions/status.py
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Dict, Any, Optional
|
|
4
|
+
|
|
5
|
+
from linkedin_cli.actions.connect import SELECTORS as CONNECT_SELECTORS
|
|
6
|
+
from linkedin_cli.actions.search import visit_profile
|
|
7
|
+
from linkedin_cli.enums import ProfileState
|
|
8
|
+
from linkedin_cli.browser.nav import find_top_card, dump_page_html
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
SELECTORS = {
|
|
13
|
+
"pending_button": '[aria-label*="Pending"]',
|
|
14
|
+
"invite_to_connect": CONNECT_SELECTORS["invite_to_connect"],
|
|
15
|
+
"more_button": CONNECT_SELECTORS["more_button"],
|
|
16
|
+
"connect_option": CONNECT_SELECTORS["connect_option"],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── API layer ──────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def _fetch_degree(session, public_identifier: str, profile: Dict[str, Any]) -> Optional[int]:
|
|
23
|
+
"""Return connection degree from API, trying two decorations.
|
|
24
|
+
|
|
25
|
+
1. Full profile scrape (FullProfileWithEntities) — mutates ``profile``
|
|
26
|
+
in place with the fresh fields and reads ``connection_degree``
|
|
27
|
+
from the response.
|
|
28
|
+
2. If that returns None, fall back to the lightweight
|
|
29
|
+
TopCardSupplementary endpoint.
|
|
30
|
+
"""
|
|
31
|
+
from linkedin_cli.api.client import PlaywrightLinkedinAPI
|
|
32
|
+
|
|
33
|
+
api = PlaywrightLinkedinAPI(session=session)
|
|
34
|
+
fresh, _raw = api.get_profile(public_identifier=public_identifier)
|
|
35
|
+
if fresh:
|
|
36
|
+
profile.update(fresh)
|
|
37
|
+
degree = profile.get("connection_degree")
|
|
38
|
+
|
|
39
|
+
if degree is None:
|
|
40
|
+
degree = api.get_connection_degree(public_identifier)
|
|
41
|
+
logger.debug("TopCard degree lookup → %s", degree)
|
|
42
|
+
|
|
43
|
+
return degree
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── UI layer ───────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def _inspect_ui(session, profile: Dict[str, Any]) -> ProfileState:
|
|
49
|
+
"""Determine connection status from profile page buttons.
|
|
50
|
+
|
|
51
|
+
Returns PENDING, QUALIFIED (connect available), or CONNECTED
|
|
52
|
+
(no connect/pending buttons found).
|
|
53
|
+
"""
|
|
54
|
+
visit_profile(session, profile)
|
|
55
|
+
session.wait()
|
|
56
|
+
top_card = find_top_card(session)
|
|
57
|
+
|
|
58
|
+
if top_card.locator(SELECTORS["pending_button"]).count() > 0:
|
|
59
|
+
logger.debug("UI → 'Pending' button detected")
|
|
60
|
+
return ProfileState.PENDING
|
|
61
|
+
|
|
62
|
+
if top_card.locator(SELECTORS["invite_to_connect"]).count() > 0:
|
|
63
|
+
logger.debug("UI → 'Connect' button detected")
|
|
64
|
+
return ProfileState.QUALIFIED
|
|
65
|
+
|
|
66
|
+
if _has_connect_in_more(session, top_card):
|
|
67
|
+
logger.debug("UI → 'Connect' in More menu")
|
|
68
|
+
return ProfileState.QUALIFIED
|
|
69
|
+
|
|
70
|
+
logger.debug("UI → no connect/pending indicators — dumping page")
|
|
71
|
+
dump_page_html(session, profile, category="status")
|
|
72
|
+
return ProfileState.QUALIFIED
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _has_connect_in_more(session, top_card) -> bool:
|
|
76
|
+
more = top_card.locator(SELECTORS["more_button"])
|
|
77
|
+
if more.count() == 0:
|
|
78
|
+
return False
|
|
79
|
+
more.first.click()
|
|
80
|
+
session.wait()
|
|
81
|
+
# Dropdown may render as a portal outside top_card, so search page-wide
|
|
82
|
+
found = session.page.locator(SELECTORS["connect_option"]).count() > 0
|
|
83
|
+
if not found:
|
|
84
|
+
session.page.keyboard.press("Escape")
|
|
85
|
+
return found
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── Public entry point ─────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def get_connection_status(
|
|
91
|
+
session: "LinkedInSession",
|
|
92
|
+
profile: Dict[str, Any],
|
|
93
|
+
) -> ProfileState:
|
|
94
|
+
"""Detect connection status via API with UI fallback.
|
|
95
|
+
|
|
96
|
+
Priority:
|
|
97
|
+
1. API degree (two decorations) — degree 1 = CONNECTED.
|
|
98
|
+
2. For degree 2/3 or None — UI inspection decides between
|
|
99
|
+
PENDING, QUALIFIED, and CONNECTED.
|
|
100
|
+
"""
|
|
101
|
+
public_identifier = profile.get("public_identifier")
|
|
102
|
+
session.ensure_browser()
|
|
103
|
+
logger.debug("Checking connection status → %s", public_identifier)
|
|
104
|
+
|
|
105
|
+
degree = _fetch_degree(session, public_identifier, profile)
|
|
106
|
+
|
|
107
|
+
if degree == 1:
|
|
108
|
+
logger.debug("API degree 1 → CONNECTED")
|
|
109
|
+
return ProfileState.CONNECTED
|
|
110
|
+
|
|
111
|
+
# degree 2/3 or None — let the UI decide
|
|
112
|
+
return _inspect_ui(session, profile)
|
|
File without changes
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# linkedin/api/client.py
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional, Any
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
|
8
|
+
|
|
9
|
+
from linkedin_cli.api.voyager import parse_linkedin_voyager_response, parse_connection_degree
|
|
10
|
+
from linkedin_cli.url_utils import url_to_public_id
|
|
11
|
+
from linkedin_cli.exceptions import (
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
ProfileInaccessibleError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _FetchResponse:
|
|
20
|
+
"""Thin wrapper around the dict returned by page.evaluate(fetch(...))."""
|
|
21
|
+
|
|
22
|
+
__slots__ = ("status", "ok", "_text")
|
|
23
|
+
|
|
24
|
+
def __init__(self, raw: dict):
|
|
25
|
+
self.status: int = raw["status"]
|
|
26
|
+
self.ok: bool = raw["ok"]
|
|
27
|
+
self._text: str = raw["body"]
|
|
28
|
+
|
|
29
|
+
def json(self) -> Any:
|
|
30
|
+
return json.loads(self._text)
|
|
31
|
+
|
|
32
|
+
def text(self) -> str:
|
|
33
|
+
return self._text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
VOYAGER_REQUEST_TIMEOUT_MS = 30_000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PlaywrightLinkedinAPI:
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
session: "LinkedInSession",
|
|
44
|
+
timeout_ms: int = VOYAGER_REQUEST_TIMEOUT_MS,
|
|
45
|
+
):
|
|
46
|
+
self.session = session
|
|
47
|
+
self.page = session.page
|
|
48
|
+
self.context = session.context
|
|
49
|
+
self.timeout_ms = timeout_ms
|
|
50
|
+
|
|
51
|
+
# Extract cookies from the browser context to get JSESSIONID for csrf-token
|
|
52
|
+
cookies = self.context.cookies()
|
|
53
|
+
cookies_dict = {c['name']: c['value'] for c in cookies}
|
|
54
|
+
jsessionid = cookies_dict.get('JSESSIONID', '').strip('"')
|
|
55
|
+
|
|
56
|
+
# Only API-level headers; fetch() inside the page inherits
|
|
57
|
+
# browser-injected headers (x-li-track, sec-ch-*, user-agent, …).
|
|
58
|
+
self.headers = {
|
|
59
|
+
'accept': 'application/vnd.linkedin.normalized+json+2.1',
|
|
60
|
+
'csrf-token': jsessionid,
|
|
61
|
+
'x-li-lang': 'en_US',
|
|
62
|
+
'x-restli-protocol-version': '2.0.0',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# ── Transport ────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
_FETCH_JS = """([method, url, headers, body, timeoutMs]) => {
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
70
|
+
const init = {method, headers, credentials: "include",
|
|
71
|
+
signal: controller.signal};
|
|
72
|
+
if (body !== null) init.body = body;
|
|
73
|
+
return fetch(url, init).then(async r => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
return {status: r.status, ok: r.ok, body: await r.text()};
|
|
76
|
+
});
|
|
77
|
+
}"""
|
|
78
|
+
|
|
79
|
+
def _fetch(self, method: str, url: str, headers: dict,
|
|
80
|
+
body: str | None = None) -> _FetchResponse:
|
|
81
|
+
"""Run fetch() inside the browser page context.
|
|
82
|
+
|
|
83
|
+
Carries all browser-injected headers (x-li-track, cookies, sec-ch-*,
|
|
84
|
+
…) exactly like a real XHR. The JS-side AbortController enforces
|
|
85
|
+
the per-request deadline; if Chromium itself dies, page.evaluate
|
|
86
|
+
raises a Playwright error, the handler fails, and reconcile
|
|
87
|
+
re-creates the task on the next idle cycle.
|
|
88
|
+
"""
|
|
89
|
+
raw = self.page.evaluate(
|
|
90
|
+
self._FETCH_JS,
|
|
91
|
+
[method, url, headers, body, self.timeout_ms],
|
|
92
|
+
)
|
|
93
|
+
return _FetchResponse(raw)
|
|
94
|
+
|
|
95
|
+
def get(self, url: str, *, headers: dict | None = None,
|
|
96
|
+
params: dict | None = None) -> _FetchResponse:
|
|
97
|
+
h = {**self.headers, **(headers or {})}
|
|
98
|
+
if params:
|
|
99
|
+
url = f"{url}?{urlencode(params)}"
|
|
100
|
+
return self._fetch("GET", url, h)
|
|
101
|
+
|
|
102
|
+
def post(self, url: str, *, headers: dict | None = None,
|
|
103
|
+
data: str | None = None) -> _FetchResponse:
|
|
104
|
+
h = {**self.headers, **(headers or {})}
|
|
105
|
+
return self._fetch("POST", url, h, body=data)
|
|
106
|
+
|
|
107
|
+
def _check_profile_response(self, res: _FetchResponse, public_identifier: str) -> None:
|
|
108
|
+
"""Raise on auth/access errors; pass through on success."""
|
|
109
|
+
if res.status == 401:
|
|
110
|
+
logger.error("LinkedIn API → 401 Unauthorized (session expired or blocked)")
|
|
111
|
+
raise AuthenticationError("LinkedIn API returned 401 Unauthorized.")
|
|
112
|
+
if res.status in (403, 404):
|
|
113
|
+
logger.info("Profile inaccessible → private / deleted / restricted → %s (HTTP %d)",
|
|
114
|
+
public_identifier, res.status)
|
|
115
|
+
raise ProfileInaccessibleError(f"{public_identifier} (HTTP {res.status})")
|
|
116
|
+
if not res.ok:
|
|
117
|
+
body_str = res.text()
|
|
118
|
+
logger.error("API request failed → %s | Status: %s", public_identifier, res.status)
|
|
119
|
+
raise IOError(f"LinkedIn API error {res.status}: {body_str[:500]}")
|
|
120
|
+
|
|
121
|
+
@retry(
|
|
122
|
+
stop=stop_after_attempt(3),
|
|
123
|
+
wait=wait_exponential(multiplier=2, min=2, max=30),
|
|
124
|
+
retry=retry_if_exception_type(IOError),
|
|
125
|
+
reraise=True,
|
|
126
|
+
)
|
|
127
|
+
def get_profile(
|
|
128
|
+
self, public_identifier: Optional[str] = None, profile_url: Optional[str] = None
|
|
129
|
+
) -> tuple[None, None] | tuple[dict, Any]:
|
|
130
|
+
if not public_identifier and profile_url:
|
|
131
|
+
public_identifier = url_to_public_id(profile_url)
|
|
132
|
+
|
|
133
|
+
if not public_identifier: # None from url_to_public_id or missing arg
|
|
134
|
+
raise ValueError("Need public_identifier or profile_url")
|
|
135
|
+
|
|
136
|
+
params = {
|
|
137
|
+
'decorationId': 'com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-91',
|
|
138
|
+
'memberIdentity': public_identifier,
|
|
139
|
+
'q': 'memberIdentity',
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
base_url = "https://www.linkedin.com/voyager/api"
|
|
143
|
+
uri = "/identity/dash/profiles"
|
|
144
|
+
full_url = base_url + uri
|
|
145
|
+
|
|
146
|
+
res = self.get(full_url, params=params)
|
|
147
|
+
|
|
148
|
+
self._check_profile_response(res, public_identifier)
|
|
149
|
+
|
|
150
|
+
data = res.json()
|
|
151
|
+
extracted_info = parse_linkedin_voyager_response(data, public_identifier=public_identifier)
|
|
152
|
+
return extracted_info, data
|
|
153
|
+
|
|
154
|
+
TOPCARD_DECORATION = (
|
|
155
|
+
"com.linkedin.voyager.dash.deco.identity.profile.TopCardSupplementary-120"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
@retry(
|
|
159
|
+
stop=stop_after_attempt(3),
|
|
160
|
+
wait=wait_exponential(multiplier=2, min=2, max=30),
|
|
161
|
+
retry=retry_if_exception_type(IOError),
|
|
162
|
+
reraise=True,
|
|
163
|
+
)
|
|
164
|
+
def get_connection_degree(self, public_identifier: str) -> int | None:
|
|
165
|
+
"""Fetch connection degree via the TopCard decoration.
|
|
166
|
+
|
|
167
|
+
Uses a lightweight decoration that reliably includes
|
|
168
|
+
MemberRelationship entities even when FullProfileWithEntities
|
|
169
|
+
does not. Returns 1/2/3 or None.
|
|
170
|
+
"""
|
|
171
|
+
res = self.get(
|
|
172
|
+
"https://www.linkedin.com/voyager/api/identity/dash/profiles",
|
|
173
|
+
params={
|
|
174
|
+
"decorationId": self.TOPCARD_DECORATION,
|
|
175
|
+
"memberIdentity": public_identifier,
|
|
176
|
+
"q": "memberIdentity",
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
self._check_profile_response(res, public_identifier)
|
|
181
|
+
|
|
182
|
+
return parse_connection_degree(res.json())
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# linkedin/api/messaging/__init__.py
|
|
2
|
+
"""Voyager Messaging API — send & retrieve messages."""
|
|
3
|
+
from linkedin_cli.api.messaging.utils import ( # noqa: F401
|
|
4
|
+
encode_urn,
|
|
5
|
+
check_response,
|
|
6
|
+
)
|
|
7
|
+
from linkedin_cli.api.messaging.send import send_message # noqa: F401
|
|
8
|
+
from linkedin_cli.api.messaging.conversations import ( # noqa: F401
|
|
9
|
+
fetch_conversations,
|
|
10
|
+
fetch_messages,
|
|
11
|
+
)
|