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.
@@ -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
+ )