linkedin-agent-cli 0.1.8__tar.gz → 0.1.9__tar.gz

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.
Files changed (38) hide show
  1. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/PKG-INFO +8 -3
  2. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/README.md +7 -2
  3. linkedin_agent_cli-0.1.9/src/linkedin_cli/actions/contact_info.py +21 -0
  4. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/api/client.py +49 -0
  5. linkedin_agent_cli-0.1.9/src/linkedin_cli/api/sdui.py +34 -0
  6. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/cli.py +24 -0
  7. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/.github/workflows/publish.yml +0 -0
  8. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/.gitignore +0 -0
  9. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/LICENSE +0 -0
  10. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/llms.txt +0 -0
  11. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/pyproject.toml +0 -0
  12. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/__init__.py +0 -0
  13. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/actions/__init__.py +0 -0
  14. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/actions/connect.py +0 -0
  15. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/actions/conversations.py +0 -0
  16. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/actions/message.py +0 -0
  17. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/actions/profile.py +0 -0
  18. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/actions/search.py +0 -0
  19. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/actions/status.py +0 -0
  20. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/api/__init__.py +0 -0
  21. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/api/messaging/__init__.py +0 -0
  22. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/api/messaging/conversations.py +0 -0
  23. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/api/messaging/send.py +0 -0
  24. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/api/messaging/utils.py +0 -0
  25. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/api/voyager.py +0 -0
  26. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/auth.py +0 -0
  27. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/browser/__init__.py +0 -0
  28. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/browser/login.py +0 -0
  29. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/browser/nav.py +0 -0
  30. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/conf.py +0 -0
  31. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/enums.py +0 -0
  32. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/exceptions.py +0 -0
  33. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/launcher.py +0 -0
  34. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/page_state.py +0 -0
  35. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/session.py +0 -0
  36. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/setup/__init__.py +0 -0
  37. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/setup/self_profile.py +0 -0
  38. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.9}/src/linkedin_cli/url_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: linkedin-agent-cli
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Summary: Django-free library and CLI for LinkedIn platform mechanics over a bound browser session (Voyager API + Playwright).
5
5
  Project-URL: Homepage, https://github.com/eracle/linkedin-cli
6
6
  Project-URL: Repository, https://github.com/eracle/linkedin-cli
@@ -52,7 +52,7 @@ it behaves like a human session instead of a cookie-only scraper.
52
52
  not a brittle one-shot form fill.
53
53
  - **Language-agnostic.** Anything that can run a subprocess and parse JSON can
54
54
  drive LinkedIn — Python, Node, Go, shell, or an AI agent. No SDK lock-in.
55
- - **Tiny surface.** Eight verbs, four dependencies, zero web framework. It knows
55
+ - **Tiny surface.** Nine verbs, four dependencies, zero web framework. It knows
56
56
  about *a LinkedIn page and a browser* — nothing else.
57
57
 
58
58
  ## 📦 Install
@@ -85,6 +85,7 @@ linkedin-cli login # authenticate the session
85
85
  linkedin-cli search "head of growth" --network first # discover → handles
86
86
  linkedin-cli profile alice-smith # scrape a profile
87
87
  linkedin-cli profile alice-smith --json > alice.json # save the full record
88
+ linkedin-cli contact-info alice-smith # email / phone (if exposed)
88
89
  linkedin-cli status alice-smith # Connected / Pending / Qualified
89
90
  linkedin-cli connect alice-smith # send a connection request
90
91
  linkedin-cli message alice-smith --text "Hi Alice 👋"
@@ -106,6 +107,7 @@ so you can clear it by hand, then carry on.
106
107
  | `whoami` | Who is this session logged in as (no login flow) | `{self}` |
107
108
  | `search <kw> [--network first/second/third] [--page N]` | People search → matching profile handles | `{query, page, network, profiles[]}` |
108
109
  | `profile <id>` | Scrape a profile (positions, education, location, …); `--raw` adds the raw Voyager blob | full `LinkedInProfile` |
110
+ | `contact-info <id>` | Email / phone from the "Contact info" overlay (only what the member exposes — usually 1st-degree); `--raw` adds the raw RSC payload | `{public_identifier, email, emails[], phone_numbers[]}` |
109
111
  | `status <id>` | Connection state | `{public_identifier, state}` |
110
112
  | `connect <id>` | Send a connection request (no note) | `{public_identifier, state}` |
111
113
  | `message <id> --text …` | Send a direct message | `{public_identifier, sent}` |
@@ -161,7 +163,10 @@ The discovery → outreach loop an agent runs: **`search` → `profile` / `statu
161
163
  database**. One session = one LinkedIn account.
162
164
  - **Voyager API** — reads (`profile`, `thread`, `status`) call LinkedIn's private
163
165
  Voyager endpoints from inside the authenticated page (`fetch`), then parse the
164
- JSON — fast and structured, no DOM scraping where an API exists.
166
+ JSON — fast and structured, no DOM scraping where an API exists. `contact-info`
167
+ forges the same server-driven-UI (RSC) POST the web app fires for the "Contact
168
+ info" overlay and parses the returned stream — same fetch-from-page technique,
169
+ different endpoint.
165
170
  - **Page-state auth machine** — `classify_page()` judges the live page by URL
166
171
  *path* only (so a `/login?...redirect=/feed/` URL never reads as the feed), and
167
172
  each transition asserts its pre/post state, raising on an illegal jump. Login,
@@ -26,7 +26,7 @@ it behaves like a human session instead of a cookie-only scraper.
26
26
  not a brittle one-shot form fill.
27
27
  - **Language-agnostic.** Anything that can run a subprocess and parse JSON can
28
28
  drive LinkedIn — Python, Node, Go, shell, or an AI agent. No SDK lock-in.
29
- - **Tiny surface.** Eight verbs, four dependencies, zero web framework. It knows
29
+ - **Tiny surface.** Nine verbs, four dependencies, zero web framework. It knows
30
30
  about *a LinkedIn page and a browser* — nothing else.
31
31
 
32
32
  ## 📦 Install
@@ -59,6 +59,7 @@ linkedin-cli login # authenticate the session
59
59
  linkedin-cli search "head of growth" --network first # discover → handles
60
60
  linkedin-cli profile alice-smith # scrape a profile
61
61
  linkedin-cli profile alice-smith --json > alice.json # save the full record
62
+ linkedin-cli contact-info alice-smith # email / phone (if exposed)
62
63
  linkedin-cli status alice-smith # Connected / Pending / Qualified
63
64
  linkedin-cli connect alice-smith # send a connection request
64
65
  linkedin-cli message alice-smith --text "Hi Alice 👋"
@@ -80,6 +81,7 @@ so you can clear it by hand, then carry on.
80
81
  | `whoami` | Who is this session logged in as (no login flow) | `{self}` |
81
82
  | `search <kw> [--network first/second/third] [--page N]` | People search → matching profile handles | `{query, page, network, profiles[]}` |
82
83
  | `profile <id>` | Scrape a profile (positions, education, location, …); `--raw` adds the raw Voyager blob | full `LinkedInProfile` |
84
+ | `contact-info <id>` | Email / phone from the "Contact info" overlay (only what the member exposes — usually 1st-degree); `--raw` adds the raw RSC payload | `{public_identifier, email, emails[], phone_numbers[]}` |
83
85
  | `status <id>` | Connection state | `{public_identifier, state}` |
84
86
  | `connect <id>` | Send a connection request (no note) | `{public_identifier, state}` |
85
87
  | `message <id> --text …` | Send a direct message | `{public_identifier, sent}` |
@@ -135,7 +137,10 @@ The discovery → outreach loop an agent runs: **`search` → `profile` / `statu
135
137
  database**. One session = one LinkedIn account.
136
138
  - **Voyager API** — reads (`profile`, `thread`, `status`) call LinkedIn's private
137
139
  Voyager endpoints from inside the authenticated page (`fetch`), then parse the
138
- JSON — fast and structured, no DOM scraping where an API exists.
140
+ JSON — fast and structured, no DOM scraping where an API exists. `contact-info`
141
+ forges the same server-driven-UI (RSC) POST the web app fires for the "Contact
142
+ info" overlay and parses the returned stream — same fetch-from-page technique,
143
+ different endpoint.
139
144
  - **Page-state auth machine** — `classify_page()` judges the live page by URL
140
145
  *path* only (so a `/login?...redirect=/feed/` URL never reads as the feed), and
141
146
  each transition asserts its pre/post state, raising on an illegal jump. Login,
@@ -0,0 +1,21 @@
1
+ # linkedin/actions/contact_info.py
2
+ import logging
3
+
4
+ from ..api.client import PlaywrightLinkedinAPI
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def get_contact_info(session, profile: dict):
10
+ """Fetch a member's contact details (email, phone) via the profile overlay."""
11
+ public_id = profile["public_identifier"]
12
+
13
+ session.ensure_browser()
14
+ session.wait()
15
+
16
+ api = PlaywrightLinkedinAPI(session=session)
17
+ logger.info("Fetching contact info → %s", public_id)
18
+ contact, raw = api.get_contact_info(public_id)
19
+ logger.info("Contact info – %s (email=%s)", public_id, bool(contact.get("email")))
20
+
21
+ return contact, raw
@@ -7,6 +7,7 @@ from urllib.parse import urlencode
7
7
  from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
8
8
 
9
9
  from linkedin_cli.api.voyager import parse_linkedin_voyager_response, parse_connection_degree
10
+ from linkedin_cli.api.sdui import parse_contact_info
10
11
  from linkedin_cli.url_utils import url_to_public_id
11
12
  from linkedin_cli.exceptions import (
12
13
  AuthenticationError,
@@ -180,3 +181,51 @@ class PlaywrightLinkedinAPI:
180
181
  self._check_profile_response(res, public_identifier)
181
182
 
182
183
  return parse_connection_degree(res.json())
184
+
185
+ # Server-driven-UI screen that renders the profile "Contact info" overlay.
186
+ SDUI_CONTACT_SCREEN = (
187
+ "com.linkedin.sdui.flagshipnav.profile.ProfileContactDetailsOverlay"
188
+ )
189
+
190
+ @retry(
191
+ stop=stop_after_attempt(3),
192
+ wait=wait_exponential(multiplier=2, min=2, max=30),
193
+ retry=retry_if_exception_type(IOError),
194
+ reraise=True,
195
+ )
196
+ def get_contact_info(self, public_identifier: str) -> tuple[dict, str]:
197
+ """Fetch a member's contact details (email, phone) from the overlay.
198
+
199
+ Forges the same server-driven-UI POST the web app fires when you open
200
+ a profile's "Contact info" overlay, and parses the RSC stream it
201
+ returns. Returns ``(parsed_dict, raw_text)``; only fields the member
202
+ exposes to your network appear — email is typically present only for
203
+ 1st-degree connections.
204
+ """
205
+ screen = self.SDUI_CONTACT_SCREEN
206
+ url = (
207
+ "https://www.linkedin.com/flagship-web/rsc-action/actions/navigation"
208
+ f"?screenId={screen}&sduiid={screen}"
209
+ )
210
+ payload = json.dumps({
211
+ "clientArguments": {
212
+ "$type": "proto.sdui.actions.requests.RequestedArguments",
213
+ "payload": {"vanityName": public_identifier, "isVanityNameResolved": True},
214
+ "requestedStateKeys": [],
215
+ "requestMetadata": {"$type": "proto.sdui.common.RequestMetadata"},
216
+ "states": [],
217
+ "screenId": screen,
218
+ },
219
+ "isModal": True,
220
+ })
221
+
222
+ res = self.post(
223
+ url,
224
+ headers={"content-type": "application/json",
225
+ "x-li-rsc-stream": "true", "accept": "*/*"},
226
+ data=payload,
227
+ )
228
+ self._check_profile_response(res, public_identifier)
229
+
230
+ text = res.text()
231
+ return parse_contact_info(text), text
@@ -0,0 +1,34 @@
1
+ """Parsers for LinkedIn's server-driven-UI (RSC) responses.
2
+
3
+ Some flagship-web surfaces — including the profile "Contact info" overlay —
4
+ are rendered via server-driven UI: a POST returns an RSC stream (React flight
5
+ format) rather than a Voyager JSON blob. These helpers pull the fields we care
6
+ about out of that text payload.
7
+ """
8
+ import re
9
+
10
+ # Contact links in the payload surface as navigate actions, e.g.
11
+ # "url":"mailto:alice@example.com" / "url":"tel:+1555..."
12
+ _MAILTO = re.compile(r"mailto:([^\"\\<>\s]+)")
13
+ _TEL = re.compile(r"tel:([^\"\\<>\s]+)")
14
+
15
+
16
+ def _unique(values: list[str]) -> list[str]:
17
+ """De-duplicate while preserving first-seen order."""
18
+ return list(dict.fromkeys(values))
19
+
20
+
21
+ def parse_contact_info(rsc_text: str) -> dict:
22
+ """Extract contact details from a ProfileContactDetailsOverlay RSC payload.
23
+
24
+ Returns ``{email, emails, phone_numbers}``. ``email`` is the first address
25
+ found (or ``None``). Only fields the member exposes to your network appear —
26
+ email is typically present only for 1st-degree connections.
27
+ """
28
+ emails = _unique(_MAILTO.findall(rsc_text))
29
+ phone_numbers = _unique(_TEL.findall(rsc_text))
30
+ return {
31
+ "email": emails[0] if emails else None,
32
+ "emails": emails,
33
+ "phone_numbers": phone_numbers,
34
+ }
@@ -122,6 +122,12 @@ def _human_profile(result: dict) -> str:
122
122
  return "\n".join(lines)
123
123
 
124
124
 
125
+ def _human_contact_info(result: dict) -> str:
126
+ lines = [f"email: {result.get('email') or '—'}"]
127
+ lines += [f"phone: {p}" for p in (result.get("phone_numbers") or [])]
128
+ return "\n".join(lines)
129
+
130
+
125
131
  def _human_thread(result: dict) -> str:
126
132
  messages = result.get("messages")
127
133
  if not messages:
@@ -151,6 +157,7 @@ _HUMAN = {
151
157
  "connect": _human_state,
152
158
  "message": _human_sent,
153
159
  "profile": _human_profile,
160
+ "contact-info": _human_contact_info,
154
161
  "thread": _human_thread,
155
162
  "search": _human_search,
156
163
  "session-close": _human_closed,
@@ -210,6 +217,17 @@ def _verb_profile(session, args) -> dict:
210
217
  return out
211
218
 
212
219
 
220
+ def _verb_contact_info(session, args) -> dict:
221
+ from linkedin_cli.actions.contact_info import get_contact_info
222
+
223
+ profile = _handle_to_profile(args.handle)
224
+ contact, raw = get_contact_info(session, profile)
225
+ out = {"public_identifier": profile["public_identifier"], **contact}
226
+ if args.raw:
227
+ out["_raw"] = raw
228
+ return out
229
+
230
+
213
231
  def _verb_status(session, args) -> dict:
214
232
  from linkedin_cli.actions.status import get_connection_status
215
233
 
@@ -256,6 +274,7 @@ _VERBS = {
256
274
  "login": _verb_login,
257
275
  "whoami": _verb_whoami,
258
276
  "profile": _verb_profile,
277
+ "contact-info": _verb_contact_info,
259
278
  "status": _verb_status,
260
279
  "connect": _verb_connect,
261
280
  "message": _verb_message,
@@ -351,6 +370,11 @@ def build_parser() -> argparse.ArgumentParser:
351
370
  p_profile.add_argument("handle", help=handle_help)
352
371
  p_profile.add_argument("--raw", action="store_true", help="Also emit the untouched Voyager blob under _raw")
353
372
 
373
+ p_contact = sub.add_parser("contact-info", parents=[common],
374
+ help="Fetch the member's contact info (email, phone) from the Contact info overlay")
375
+ p_contact.add_argument("handle", help=handle_help)
376
+ p_contact.add_argument("--raw", action="store_true", help="Also emit the untouched RSC payload under _raw")
377
+
354
378
  sub.add_parser("status", parents=[common],
355
379
  help="Report the connection state with the member: Connected, Pending, or Qualified"
356
380
  ).add_argument("handle", help=handle_help)