linkedin-agent-cli 0.1.8__tar.gz → 0.1.10__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.10}/PKG-INFO +10 -3
  2. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/README.md +9 -2
  3. linkedin_agent_cli-0.1.10/src/linkedin_cli/actions/contact_info.py +21 -0
  4. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/api/client.py +49 -0
  5. linkedin_agent_cli-0.1.10/src/linkedin_cli/api/sdui.py +34 -0
  6. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/cli.py +41 -2
  7. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/.github/workflows/publish.yml +0 -0
  8. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/.gitignore +0 -0
  9. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/LICENSE +0 -0
  10. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/llms.txt +0 -0
  11. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/pyproject.toml +0 -0
  12. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/__init__.py +0 -0
  13. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/actions/__init__.py +0 -0
  14. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/actions/connect.py +0 -0
  15. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/actions/conversations.py +0 -0
  16. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/actions/message.py +0 -0
  17. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/actions/profile.py +0 -0
  18. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/actions/search.py +0 -0
  19. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/actions/status.py +0 -0
  20. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/api/__init__.py +0 -0
  21. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/api/messaging/__init__.py +0 -0
  22. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/api/messaging/conversations.py +0 -0
  23. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/api/messaging/send.py +0 -0
  24. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/api/messaging/utils.py +0 -0
  25. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/api/voyager.py +0 -0
  26. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/auth.py +0 -0
  27. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/browser/__init__.py +0 -0
  28. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/browser/login.py +0 -0
  29. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/browser/nav.py +0 -0
  30. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/conf.py +0 -0
  31. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/enums.py +0 -0
  32. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/exceptions.py +0 -0
  33. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/launcher.py +0 -0
  34. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/page_state.py +0 -0
  35. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/session.py +0 -0
  36. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/setup/__init__.py +0 -0
  37. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/src/linkedin_cli/setup/self_profile.py +0 -0
  38. {linkedin_agent_cli-0.1.8 → linkedin_agent_cli-0.1.10}/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.10
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,8 +85,10 @@ 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
91
+ linkedin-cli connect-author # connect with this tool's author
90
92
  linkedin-cli message alice-smith --text "Hi Alice 👋"
91
93
  linkedin-cli thread alice-smith # read the conversation
92
94
 
@@ -106,8 +108,10 @@ so you can clear it by hand, then carry on.
106
108
  | `whoami` | Who is this session logged in as (no login flow) | `{self}` |
107
109
  | `search <kw> [--network first/second/third] [--page N]` | People search → matching profile handles | `{query, page, network, profiles[]}` |
108
110
  | `profile <id>` | Scrape a profile (positions, education, location, …); `--raw` adds the raw Voyager blob | full `LinkedInProfile` |
111
+ | `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
112
  | `status <id>` | Connection state | `{public_identifier, state}` |
110
113
  | `connect <id>` | Send a connection request (no note) | `{public_identifier, state}` |
114
+ | `connect-author` | Send a connection request to this tool's author (`linkedin.com/in/eracle`) — no handle needed | `{public_identifier, state}` |
111
115
  | `message <id> --text …` | Send a direct message | `{public_identifier, sent}` |
112
116
  | `thread <id>` | Read a conversation's messages | `{public_identifier, messages[]}` |
113
117
 
@@ -161,7 +165,10 @@ The discovery → outreach loop an agent runs: **`search` → `profile` / `statu
161
165
  database**. One session = one LinkedIn account.
162
166
  - **Voyager API** — reads (`profile`, `thread`, `status`) call LinkedIn's private
163
167
  Voyager endpoints from inside the authenticated page (`fetch`), then parse the
164
- JSON — fast and structured, no DOM scraping where an API exists.
168
+ JSON — fast and structured, no DOM scraping where an API exists. `contact-info`
169
+ forges the same server-driven-UI (RSC) POST the web app fires for the "Contact
170
+ info" overlay and parses the returned stream — same fetch-from-page technique,
171
+ different endpoint.
165
172
  - **Page-state auth machine** — `classify_page()` judges the live page by URL
166
173
  *path* only (so a `/login?...redirect=/feed/` URL never reads as the feed), and
167
174
  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,8 +59,10 @@ 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
65
+ linkedin-cli connect-author # connect with this tool's author
64
66
  linkedin-cli message alice-smith --text "Hi Alice 👋"
65
67
  linkedin-cli thread alice-smith # read the conversation
66
68
 
@@ -80,8 +82,10 @@ so you can clear it by hand, then carry on.
80
82
  | `whoami` | Who is this session logged in as (no login flow) | `{self}` |
81
83
  | `search <kw> [--network first/second/third] [--page N]` | People search → matching profile handles | `{query, page, network, profiles[]}` |
82
84
  | `profile <id>` | Scrape a profile (positions, education, location, …); `--raw` adds the raw Voyager blob | full `LinkedInProfile` |
85
+ | `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
86
  | `status <id>` | Connection state | `{public_identifier, state}` |
84
87
  | `connect <id>` | Send a connection request (no note) | `{public_identifier, state}` |
88
+ | `connect-author` | Send a connection request to this tool's author (`linkedin.com/in/eracle`) — no handle needed | `{public_identifier, state}` |
85
89
  | `message <id> --text …` | Send a direct message | `{public_identifier, sent}` |
86
90
  | `thread <id>` | Read a conversation's messages | `{public_identifier, messages[]}` |
87
91
 
@@ -135,7 +139,10 @@ The discovery → outreach loop an agent runs: **`search` → `profile` / `statu
135
139
  database**. One session = one LinkedIn account.
136
140
  - **Voyager API** — reads (`profile`, `thread`, `status`) call LinkedIn's private
137
141
  Voyager endpoints from inside the authenticated page (`fetch`), then parse the
138
- JSON — fast and structured, no DOM scraping where an API exists.
142
+ JSON — fast and structured, no DOM scraping where an API exists. `contact-info`
143
+ forges the same server-driven-UI (RSC) POST the web app fires for the "Contact
144
+ info" overlay and parses the returned stream — same fetch-from-page technique,
145
+ different endpoint.
139
146
  - **Page-state auth machine** — `classify_page()` judges the live page by URL
140
147
  *path* only (so a `/login?...redirect=/feed/` URL never reads as the feed), and
141
148
  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
+ }
@@ -51,6 +51,9 @@ logger = logging.getLogger("linkedin_cli")
51
51
  DEFAULT_MIN_PACE_S = 5.0
52
52
  DEFAULT_MAX_PACE_S = 8.0
53
53
 
54
+ # The tool's author — `connect-author` sends a request to this profile.
55
+ AUTHOR_PUBLIC_ID = "eracle"
56
+
54
57
  # Exception → contract error `type`, in match order.
55
58
  _ERROR_TYPES = [
56
59
  (CheckpointChallengeError, "checkpoint_challenge"),
@@ -122,6 +125,12 @@ def _human_profile(result: dict) -> str:
122
125
  return "\n".join(lines)
123
126
 
124
127
 
128
+ def _human_contact_info(result: dict) -> str:
129
+ lines = [f"email: {result.get('email') or '—'}"]
130
+ lines += [f"phone: {p}" for p in (result.get("phone_numbers") or [])]
131
+ return "\n".join(lines)
132
+
133
+
125
134
  def _human_thread(result: dict) -> str:
126
135
  messages = result.get("messages")
127
136
  if not messages:
@@ -149,8 +158,10 @@ _HUMAN = {
149
158
  "whoami": _human_identity,
150
159
  "status": _human_state,
151
160
  "connect": _human_state,
161
+ "connect-author": _human_state,
152
162
  "message": _human_sent,
153
163
  "profile": _human_profile,
164
+ "contact-info": _human_contact_info,
154
165
  "thread": _human_thread,
155
166
  "search": _human_search,
156
167
  "session-close": _human_closed,
@@ -210,6 +221,17 @@ def _verb_profile(session, args) -> dict:
210
221
  return out
211
222
 
212
223
 
224
+ def _verb_contact_info(session, args) -> dict:
225
+ from linkedin_cli.actions.contact_info import get_contact_info
226
+
227
+ profile = _handle_to_profile(args.handle)
228
+ contact, raw = get_contact_info(session, profile)
229
+ out = {"public_identifier": profile["public_identifier"], **contact}
230
+ if args.raw:
231
+ out["_raw"] = raw
232
+ return out
233
+
234
+
213
235
  def _verb_status(session, args) -> dict:
214
236
  from linkedin_cli.actions.status import get_connection_status
215
237
 
@@ -218,17 +240,25 @@ def _verb_status(session, args) -> dict:
218
240
  return {"public_identifier": profile["public_identifier"], "state": state.value}
219
241
 
220
242
 
221
- def _verb_connect(session, args) -> dict:
243
+ def _connect(session, profile: dict) -> dict:
244
+ """Send a connection request unless already Connected/Pending; report the state."""
222
245
  from linkedin_cli.actions.connect import send_connection_request
223
246
  from linkedin_cli.actions.status import get_connection_status
224
247
 
225
- profile = _handle_to_profile(args.handle)
226
248
  state = get_connection_status(session, profile)
227
249
  if state not in (ProfileState.CONNECTED, ProfileState.PENDING):
228
250
  state = send_connection_request(session, profile)
229
251
  return {"public_identifier": profile["public_identifier"], "state": state.value}
230
252
 
231
253
 
254
+ def _verb_connect(session, args) -> dict:
255
+ return _connect(session, _handle_to_profile(args.handle))
256
+
257
+
258
+ def _verb_connect_author(session, args) -> dict:
259
+ return _connect(session, _handle_to_profile(AUTHOR_PUBLIC_ID))
260
+
261
+
232
262
  def _verb_message(session, args) -> dict:
233
263
  from linkedin_cli.actions.message import send_raw_message
234
264
 
@@ -256,8 +286,10 @@ _VERBS = {
256
286
  "login": _verb_login,
257
287
  "whoami": _verb_whoami,
258
288
  "profile": _verb_profile,
289
+ "contact-info": _verb_contact_info,
259
290
  "status": _verb_status,
260
291
  "connect": _verb_connect,
292
+ "connect-author": _verb_connect_author,
261
293
  "message": _verb_message,
262
294
  "thread": _verb_thread,
263
295
  "search": _verb_search,
@@ -351,12 +383,19 @@ def build_parser() -> argparse.ArgumentParser:
351
383
  p_profile.add_argument("handle", help=handle_help)
352
384
  p_profile.add_argument("--raw", action="store_true", help="Also emit the untouched Voyager blob under _raw")
353
385
 
386
+ p_contact = sub.add_parser("contact-info", parents=[common],
387
+ help="Fetch the member's contact info (email, phone) from the Contact info overlay")
388
+ p_contact.add_argument("handle", help=handle_help)
389
+ p_contact.add_argument("--raw", action="store_true", help="Also emit the untouched RSC payload under _raw")
390
+
354
391
  sub.add_parser("status", parents=[common],
355
392
  help="Report the connection state with the member: Connected, Pending, or Qualified"
356
393
  ).add_argument("handle", help=handle_help)
357
394
  sub.add_parser("connect", parents=[common],
358
395
  help="Send a connection request (no note); no-op if already Connected or Pending"
359
396
  ).add_argument("handle", help=handle_help)
397
+ sub.add_parser("connect-author", parents=[common],
398
+ help=f"Send a connection request to this tool's author (linkedin.com/in/{AUTHOR_PUBLIC_ID})")
360
399
  sub.add_parser("thread", parents=[common],
361
400
  help="Dump the conversation with the member as a list of messages (newest last)"
362
401
  ).add_argument("handle", help=handle_help)