linkedin-agent-cli 0.1.0__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.
- linkedin_agent_cli-0.1.0/.gitignore +8 -0
- linkedin_agent_cli-0.1.0/LICENSE +21 -0
- linkedin_agent_cli-0.1.0/PKG-INFO +197 -0
- linkedin_agent_cli-0.1.0/README.md +171 -0
- linkedin_agent_cli-0.1.0/llms.txt +54 -0
- linkedin_agent_cli-0.1.0/pyproject.toml +44 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/__init__.py +9 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/actions/__init__.py +0 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/actions/connect.py +118 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/actions/conversations.py +132 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/actions/message.py +153 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/actions/profile.py +22 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/actions/search.py +186 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/actions/status.py +112 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/api/__init__.py +0 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/api/client.py +182 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/api/messaging/__init__.py +11 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/api/messaging/conversations.py +56 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/api/messaging/send.py +74 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/api/messaging/utils.py +24 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/api/voyager.py +319 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/auth.py +98 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/browser/__init__.py +0 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/browser/login.py +140 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/browser/nav.py +115 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/cli.py +396 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/conf.py +33 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/enums.py +11 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/exceptions.py +47 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/launcher.py +60 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/page_state.py +148 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/session.py +169 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/setup/__init__.py +0 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/setup/self_profile.py +25 -0
- linkedin_agent_cli-0.1.0/src/linkedin_cli/url_utils.py +30 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 eracle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linkedin-agent-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django-free library and CLI for LinkedIn platform mechanics over a bound browser session (Voyager API + Playwright).
|
|
5
|
+
Project-URL: Homepage, https://github.com/eracle/linkedin-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/eracle/linkedin-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/eracle/linkedin-cli/issues
|
|
8
|
+
Author-email: eracle <eracle@posteo.eu>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent-tools,ai-agent,browser-automation,cli,lead-generation,linkedin,linkedin-api,linkedin-automation,linkedin-scraper,llm,outreach,playwright,voyager,web-scraping
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
18
|
+
Classifier: Topic :: Office/Business
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: playwright-stealth
|
|
22
|
+
Requires-Dist: playwright>=1.59
|
|
23
|
+
Requires-Dist: tenacity<10,>=8
|
|
24
|
+
Requires-Dist: termcolor
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# linkedin-cli
|
|
28
|
+
|
|
29
|
+
**Drive LinkedIn from the command line or any program** — search people, scrape
|
|
30
|
+
profiles, check connection status, send connection requests, and read or send
|
|
31
|
+
messages. One small, dependency-light Python tool that talks to LinkedIn's
|
|
32
|
+
private **Voyager API** through a **real, logged-in browser** (Playwright), so
|
|
33
|
+
it behaves like a human session instead of a cookie-only scraper.
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+

|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
> No SaaS, no API key, no database. Your browser, your LinkedIn account, your machine.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## ✨ Why linkedin-cli
|
|
44
|
+
|
|
45
|
+
- **Real browser session, not raw cookies.** A persistent Chromium window is
|
|
46
|
+
launched once and shared; requests ride your live, authenticated session —
|
|
47
|
+
far more resilient than header/cookie replay.
|
|
48
|
+
- **Structured JSON out of every command.** Pipe it into `jq`, a script, or an
|
|
49
|
+
LLM agent. Human-readable summaries by default; `--json` for the full record.
|
|
50
|
+
- **Robust login.** Authentication is a small **page-state machine** that
|
|
51
|
+
understands LinkedIn's login, authwall, and security-checkpoint redirects —
|
|
52
|
+
not a brittle one-shot form fill.
|
|
53
|
+
- **Language-agnostic.** Anything that can run a subprocess and parse JSON can
|
|
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
|
|
56
|
+
about *a LinkedIn page and a browser* — nothing else.
|
|
57
|
+
|
|
58
|
+
## 📦 Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install linkedin-agent-cli
|
|
62
|
+
python -m playwright install chromium
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
This installs the `linkedin-cli` command (equivalent to `python -m linkedin_cli.cli`).
|
|
66
|
+
The PyPI package is `linkedin-agent-cli`; the import name is `linkedin_cli`. For the
|
|
67
|
+
latest unreleased code, install from git:
|
|
68
|
+
`pip install "linkedin-agent-cli @ git+https://github.com/eracle/linkedin-cli.git@main"`.
|
|
69
|
+
|
|
70
|
+
## 🚀 Quickstart
|
|
71
|
+
|
|
72
|
+
linkedin-cli uses a **bind + connect** model: one long-lived process owns the
|
|
73
|
+
browser; every command is a short client that connects to it.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# 1. Open + bind a session once (this process owns the browser window).
|
|
77
|
+
linkedin-cli session open --session work
|
|
78
|
+
|
|
79
|
+
# 2. From any other shell, drive it. Set the session once via env:
|
|
80
|
+
export LINKEDIN_CLI_SESSION=work
|
|
81
|
+
export LINKEDIN_USERNAME="you@example.com"
|
|
82
|
+
export LINKEDIN_PASSWORD="••••••••"
|
|
83
|
+
|
|
84
|
+
linkedin-cli login # authenticate the session
|
|
85
|
+
linkedin-cli search "head of growth" --network first # discover → handles
|
|
86
|
+
linkedin-cli profile alice-smith # scrape a profile
|
|
87
|
+
linkedin-cli profile alice-smith --json > alice.json # save the full record
|
|
88
|
+
linkedin-cli status alice-smith # Connected / Pending / Qualified
|
|
89
|
+
linkedin-cli connect alice-smith # send a connection request
|
|
90
|
+
linkedin-cli message alice-smith --text "Hi Alice 👋"
|
|
91
|
+
linkedin-cli thread alice-smith # read the conversation
|
|
92
|
+
|
|
93
|
+
linkedin-cli session close
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Hit a security checkpoint? `playwright-cli attach work` opens the *same* browser
|
|
97
|
+
so you can clear it by hand, then carry on.
|
|
98
|
+
|
|
99
|
+
## 🧰 Commands
|
|
100
|
+
|
|
101
|
+
`--session <name>` (or `$LINKEDIN_CLI_SESSION`) and `--json` apply to every command.
|
|
102
|
+
|
|
103
|
+
| Command | What it does | `--json` result |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `login` | Authenticate the session (creds from env), clear checkpoints, discover your own profile | `{account, self}` |
|
|
106
|
+
| `whoami` | Who is this session logged in as (no login flow) | `{self}` |
|
|
107
|
+
| `search <kw> [--network first/second/third] [--page N]` | People search → matching profile handles | `{query, page, network, profiles[]}` |
|
|
108
|
+
| `profile <id>` | Scrape a profile (positions, education, location, …); `--raw` adds the raw Voyager blob | full `LinkedInProfile` |
|
|
109
|
+
| `status <id>` | Connection state | `{public_identifier, state}` |
|
|
110
|
+
| `connect <id>` | Send a connection request (no note) | `{public_identifier, state}` |
|
|
111
|
+
| `message <id> --text …` | Send a direct message | `{public_identifier, sent}` |
|
|
112
|
+
| `thread <id>` | Read a conversation's messages | `{public_identifier, messages[]}` |
|
|
113
|
+
|
|
114
|
+
An `<id>` is a public handle (`alice-smith`) or a full profile URL. Commands that
|
|
115
|
+
need the internal member `urn` (`message`/`thread`/`status`) resolve it for you —
|
|
116
|
+
every command is independent and takes only a handle.
|
|
117
|
+
|
|
118
|
+
## 🤖 Built for AI agents (and any language)
|
|
119
|
+
|
|
120
|
+
linkedin-cli is designed to be driven by an LLM as a **deterministic tool**. The
|
|
121
|
+
properties that make it agent-friendly:
|
|
122
|
+
|
|
123
|
+
- **Stable, typed JSON contract** — every verb returns one documented dict;
|
|
124
|
+
maps directly onto a function-calling / tool-use schema.
|
|
125
|
+
- **id-only, stateless commands** — a public handle is the only argument an agent
|
|
126
|
+
threads between steps; no session tokens, urns, or cursors to carry.
|
|
127
|
+
- **Predictable error taxonomy** — failures surface as `error: <type>: <message>`
|
|
128
|
+
on stderr with a non-zero exit, so an agent can branch on `type`
|
|
129
|
+
(`checkpoint_challenge`, `authentication`, `connection_limit`, …).
|
|
130
|
+
- **No hidden state or side effects** — stdout is result-only; logs go to stderr.
|
|
131
|
+
- **Self-describing** — see [`llms.txt`](llms.txt) for a compact spec an LLM can
|
|
132
|
+
load directly, and `linkedin-cli <verb> --help` for per-verb usage.
|
|
133
|
+
|
|
134
|
+
Because every command emits JSON on stdout, you can drive LinkedIn from anything —
|
|
135
|
+
Python, Node, Go, shell, or an agent loop — no SDK and no Python import required:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
import subprocess, json
|
|
139
|
+
|
|
140
|
+
def li(*args):
|
|
141
|
+
out = subprocess.run(["linkedin-cli", *args, "--json"],
|
|
142
|
+
capture_output=True, text=True, check=True)
|
|
143
|
+
return json.loads(out.stdout)
|
|
144
|
+
|
|
145
|
+
for hit in li("search", "head of growth", "--network", "first")["profiles"]:
|
|
146
|
+
handle = hit["public_identifier"]
|
|
147
|
+
if li("status", handle)["state"] == "Qualified":
|
|
148
|
+
li("message", handle, "--text", "Hi — loved your recent post!")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The discovery → outreach loop an agent runs: **`search` → `profile` / `status` →
|
|
152
|
+
`message` / `thread`.**
|
|
153
|
+
|
|
154
|
+
## 🧠 How it works
|
|
155
|
+
|
|
156
|
+
- **bind + connect** — `linkedin-cli session open` launches a persistent Chromium
|
|
157
|
+
with `Browser.bind()` (Playwright ≥ 1.59) and registers a local `ws://`
|
|
158
|
+
endpoint under the session name. Each command `chromium.connect()`s to that same
|
|
159
|
+
browser and drives a *real* page. Auth, cookies, and fingerprint live in the
|
|
160
|
+
owner's on-disk profile; the CLI keeps only a name→endpoint registry — **no
|
|
161
|
+
database**. One session = one LinkedIn account.
|
|
162
|
+
- **Voyager API** — reads (`profile`, `thread`, `status`) call LinkedIn's private
|
|
163
|
+
Voyager endpoints from inside the authenticated page (`fetch`), then parse the
|
|
164
|
+
JSON — fast and structured, no DOM scraping where an API exists.
|
|
165
|
+
- **Page-state auth machine** — `classify_page()` judges the live page by URL
|
|
166
|
+
*path* only (so a `/login?...redirect=/feed/` URL never reads as the feed), and
|
|
167
|
+
each transition asserts its pre/post state, raising on an illegal jump. Login,
|
|
168
|
+
authwall, and checkpoint flows are modeled explicitly.
|
|
169
|
+
|
|
170
|
+
## 📤 Output contract
|
|
171
|
+
|
|
172
|
+
- Every command produces one result **dict** — that dict is both the `--json`
|
|
173
|
+
payload and the source the human summary is rendered from, so the two never drift.
|
|
174
|
+
- **Human-readable by default; `--json` for the full dict.**
|
|
175
|
+
- **No `--out` flag** — print to stdout, redirect to save (`… --json > out.json`).
|
|
176
|
+
- **stdout is result-only; logs and errors go to stderr** as
|
|
177
|
+
`error: <type>: <message>` with a non-zero exit. Error types are stable:
|
|
178
|
+
`checkpoint_challenge`, `authentication`, `profile_inaccessible`,
|
|
179
|
+
`skip_profile`, `connection_limit`.
|
|
180
|
+
|
|
181
|
+
## ⚠️ Responsible use
|
|
182
|
+
|
|
183
|
+
This tool automates **your own** LinkedIn account from **your own** machine.
|
|
184
|
+
Automating LinkedIn may conflict with its Terms of Service, and aggressive use
|
|
185
|
+
can get an account restricted. Respect rate limits, only contact people for
|
|
186
|
+
legitimate reasons, follow applicable laws (GDPR/CAN-SPAM), and use it at your
|
|
187
|
+
own risk. You are responsible for how you use it.
|
|
188
|
+
|
|
189
|
+
## 📄 License
|
|
190
|
+
|
|
191
|
+
[MIT](LICENSE) © eracle
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
linkedin-cli was extracted from [**OpenOutreach**](https://github.com/eracle/OpenOutreach),
|
|
196
|
+
an open-source AI outreach tool, where it powers the LinkedIn discovery and
|
|
197
|
+
interaction layer. It is fully standalone and reusable on its own.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# linkedin-cli
|
|
2
|
+
|
|
3
|
+
**Drive LinkedIn from the command line or any program** — search people, scrape
|
|
4
|
+
profiles, check connection status, send connection requests, and read or send
|
|
5
|
+
messages. One small, dependency-light Python tool that talks to LinkedIn's
|
|
6
|
+
private **Voyager API** through a **real, logged-in browser** (Playwright), so
|
|
7
|
+
it behaves like a human session instead of a cookie-only scraper.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+

|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
> No SaaS, no API key, no database. Your browser, your LinkedIn account, your machine.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## ✨ Why linkedin-cli
|
|
18
|
+
|
|
19
|
+
- **Real browser session, not raw cookies.** A persistent Chromium window is
|
|
20
|
+
launched once and shared; requests ride your live, authenticated session —
|
|
21
|
+
far more resilient than header/cookie replay.
|
|
22
|
+
- **Structured JSON out of every command.** Pipe it into `jq`, a script, or an
|
|
23
|
+
LLM agent. Human-readable summaries by default; `--json` for the full record.
|
|
24
|
+
- **Robust login.** Authentication is a small **page-state machine** that
|
|
25
|
+
understands LinkedIn's login, authwall, and security-checkpoint redirects —
|
|
26
|
+
not a brittle one-shot form fill.
|
|
27
|
+
- **Language-agnostic.** Anything that can run a subprocess and parse JSON can
|
|
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
|
|
30
|
+
about *a LinkedIn page and a browser* — nothing else.
|
|
31
|
+
|
|
32
|
+
## 📦 Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install linkedin-agent-cli
|
|
36
|
+
python -m playwright install chromium
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This installs the `linkedin-cli` command (equivalent to `python -m linkedin_cli.cli`).
|
|
40
|
+
The PyPI package is `linkedin-agent-cli`; the import name is `linkedin_cli`. For the
|
|
41
|
+
latest unreleased code, install from git:
|
|
42
|
+
`pip install "linkedin-agent-cli @ git+https://github.com/eracle/linkedin-cli.git@main"`.
|
|
43
|
+
|
|
44
|
+
## 🚀 Quickstart
|
|
45
|
+
|
|
46
|
+
linkedin-cli uses a **bind + connect** model: one long-lived process owns the
|
|
47
|
+
browser; every command is a short client that connects to it.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# 1. Open + bind a session once (this process owns the browser window).
|
|
51
|
+
linkedin-cli session open --session work
|
|
52
|
+
|
|
53
|
+
# 2. From any other shell, drive it. Set the session once via env:
|
|
54
|
+
export LINKEDIN_CLI_SESSION=work
|
|
55
|
+
export LINKEDIN_USERNAME="you@example.com"
|
|
56
|
+
export LINKEDIN_PASSWORD="••••••••"
|
|
57
|
+
|
|
58
|
+
linkedin-cli login # authenticate the session
|
|
59
|
+
linkedin-cli search "head of growth" --network first # discover → handles
|
|
60
|
+
linkedin-cli profile alice-smith # scrape a profile
|
|
61
|
+
linkedin-cli profile alice-smith --json > alice.json # save the full record
|
|
62
|
+
linkedin-cli status alice-smith # Connected / Pending / Qualified
|
|
63
|
+
linkedin-cli connect alice-smith # send a connection request
|
|
64
|
+
linkedin-cli message alice-smith --text "Hi Alice 👋"
|
|
65
|
+
linkedin-cli thread alice-smith # read the conversation
|
|
66
|
+
|
|
67
|
+
linkedin-cli session close
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Hit a security checkpoint? `playwright-cli attach work` opens the *same* browser
|
|
71
|
+
so you can clear it by hand, then carry on.
|
|
72
|
+
|
|
73
|
+
## 🧰 Commands
|
|
74
|
+
|
|
75
|
+
`--session <name>` (or `$LINKEDIN_CLI_SESSION`) and `--json` apply to every command.
|
|
76
|
+
|
|
77
|
+
| Command | What it does | `--json` result |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `login` | Authenticate the session (creds from env), clear checkpoints, discover your own profile | `{account, self}` |
|
|
80
|
+
| `whoami` | Who is this session logged in as (no login flow) | `{self}` |
|
|
81
|
+
| `search <kw> [--network first/second/third] [--page N]` | People search → matching profile handles | `{query, page, network, profiles[]}` |
|
|
82
|
+
| `profile <id>` | Scrape a profile (positions, education, location, …); `--raw` adds the raw Voyager blob | full `LinkedInProfile` |
|
|
83
|
+
| `status <id>` | Connection state | `{public_identifier, state}` |
|
|
84
|
+
| `connect <id>` | Send a connection request (no note) | `{public_identifier, state}` |
|
|
85
|
+
| `message <id> --text …` | Send a direct message | `{public_identifier, sent}` |
|
|
86
|
+
| `thread <id>` | Read a conversation's messages | `{public_identifier, messages[]}` |
|
|
87
|
+
|
|
88
|
+
An `<id>` is a public handle (`alice-smith`) or a full profile URL. Commands that
|
|
89
|
+
need the internal member `urn` (`message`/`thread`/`status`) resolve it for you —
|
|
90
|
+
every command is independent and takes only a handle.
|
|
91
|
+
|
|
92
|
+
## 🤖 Built for AI agents (and any language)
|
|
93
|
+
|
|
94
|
+
linkedin-cli is designed to be driven by an LLM as a **deterministic tool**. The
|
|
95
|
+
properties that make it agent-friendly:
|
|
96
|
+
|
|
97
|
+
- **Stable, typed JSON contract** — every verb returns one documented dict;
|
|
98
|
+
maps directly onto a function-calling / tool-use schema.
|
|
99
|
+
- **id-only, stateless commands** — a public handle is the only argument an agent
|
|
100
|
+
threads between steps; no session tokens, urns, or cursors to carry.
|
|
101
|
+
- **Predictable error taxonomy** — failures surface as `error: <type>: <message>`
|
|
102
|
+
on stderr with a non-zero exit, so an agent can branch on `type`
|
|
103
|
+
(`checkpoint_challenge`, `authentication`, `connection_limit`, …).
|
|
104
|
+
- **No hidden state or side effects** — stdout is result-only; logs go to stderr.
|
|
105
|
+
- **Self-describing** — see [`llms.txt`](llms.txt) for a compact spec an LLM can
|
|
106
|
+
load directly, and `linkedin-cli <verb> --help` for per-verb usage.
|
|
107
|
+
|
|
108
|
+
Because every command emits JSON on stdout, you can drive LinkedIn from anything —
|
|
109
|
+
Python, Node, Go, shell, or an agent loop — no SDK and no Python import required:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
import subprocess, json
|
|
113
|
+
|
|
114
|
+
def li(*args):
|
|
115
|
+
out = subprocess.run(["linkedin-cli", *args, "--json"],
|
|
116
|
+
capture_output=True, text=True, check=True)
|
|
117
|
+
return json.loads(out.stdout)
|
|
118
|
+
|
|
119
|
+
for hit in li("search", "head of growth", "--network", "first")["profiles"]:
|
|
120
|
+
handle = hit["public_identifier"]
|
|
121
|
+
if li("status", handle)["state"] == "Qualified":
|
|
122
|
+
li("message", handle, "--text", "Hi — loved your recent post!")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The discovery → outreach loop an agent runs: **`search` → `profile` / `status` →
|
|
126
|
+
`message` / `thread`.**
|
|
127
|
+
|
|
128
|
+
## 🧠 How it works
|
|
129
|
+
|
|
130
|
+
- **bind + connect** — `linkedin-cli session open` launches a persistent Chromium
|
|
131
|
+
with `Browser.bind()` (Playwright ≥ 1.59) and registers a local `ws://`
|
|
132
|
+
endpoint under the session name. Each command `chromium.connect()`s to that same
|
|
133
|
+
browser and drives a *real* page. Auth, cookies, and fingerprint live in the
|
|
134
|
+
owner's on-disk profile; the CLI keeps only a name→endpoint registry — **no
|
|
135
|
+
database**. One session = one LinkedIn account.
|
|
136
|
+
- **Voyager API** — reads (`profile`, `thread`, `status`) call LinkedIn's private
|
|
137
|
+
Voyager endpoints from inside the authenticated page (`fetch`), then parse the
|
|
138
|
+
JSON — fast and structured, no DOM scraping where an API exists.
|
|
139
|
+
- **Page-state auth machine** — `classify_page()` judges the live page by URL
|
|
140
|
+
*path* only (so a `/login?...redirect=/feed/` URL never reads as the feed), and
|
|
141
|
+
each transition asserts its pre/post state, raising on an illegal jump. Login,
|
|
142
|
+
authwall, and checkpoint flows are modeled explicitly.
|
|
143
|
+
|
|
144
|
+
## 📤 Output contract
|
|
145
|
+
|
|
146
|
+
- Every command produces one result **dict** — that dict is both the `--json`
|
|
147
|
+
payload and the source the human summary is rendered from, so the two never drift.
|
|
148
|
+
- **Human-readable by default; `--json` for the full dict.**
|
|
149
|
+
- **No `--out` flag** — print to stdout, redirect to save (`… --json > out.json`).
|
|
150
|
+
- **stdout is result-only; logs and errors go to stderr** as
|
|
151
|
+
`error: <type>: <message>` with a non-zero exit. Error types are stable:
|
|
152
|
+
`checkpoint_challenge`, `authentication`, `profile_inaccessible`,
|
|
153
|
+
`skip_profile`, `connection_limit`.
|
|
154
|
+
|
|
155
|
+
## ⚠️ Responsible use
|
|
156
|
+
|
|
157
|
+
This tool automates **your own** LinkedIn account from **your own** machine.
|
|
158
|
+
Automating LinkedIn may conflict with its Terms of Service, and aggressive use
|
|
159
|
+
can get an account restricted. Respect rate limits, only contact people for
|
|
160
|
+
legitimate reasons, follow applicable laws (GDPR/CAN-SPAM), and use it at your
|
|
161
|
+
own risk. You are responsible for how you use it.
|
|
162
|
+
|
|
163
|
+
## 📄 License
|
|
164
|
+
|
|
165
|
+
[MIT](LICENSE) © eracle
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
linkedin-cli was extracted from [**OpenOutreach**](https://github.com/eracle/OpenOutreach),
|
|
170
|
+
an open-source AI outreach tool, where it powers the LinkedIn discovery and
|
|
171
|
+
interaction layer. It is fully standalone and reusable on its own.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# linkedin-cli
|
|
2
|
+
|
|
3
|
+
> A command-line tool and Python library that drives LinkedIn through a real,
|
|
4
|
+
> logged-in browser (Playwright) and LinkedIn's private Voyager API. It is meant
|
|
5
|
+
> to be used by LLM agents as a deterministic tool: every command takes a public
|
|
6
|
+
> profile handle and emits a single JSON object on stdout. Logs and errors go to
|
|
7
|
+
> stderr. There is no API key, no SaaS, and no database — it drives the user's own
|
|
8
|
+
> LinkedIn account on the user's own machine.
|
|
9
|
+
|
|
10
|
+
## How to use it
|
|
11
|
+
|
|
12
|
+
A session owner launches and binds one persistent browser; each command connects
|
|
13
|
+
to it. Select the session with `--session <name>` or `$LINKEDIN_CLI_SESSION`.
|
|
14
|
+
Add `--json` to any verb for the full result dict (the default prints a short
|
|
15
|
+
human summary). Credentials come from `$LINKEDIN_USERNAME` / `$LINKEDIN_PASSWORD`.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
linkedin-cli session open --session work # start once; owns the browser, blocks
|
|
19
|
+
linkedin-cli login # authenticate the bound session
|
|
20
|
+
linkedin-cli session close # stop the session
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
An `<id>` argument is a public handle (e.g. `alice-smith`) or a profile URL.
|
|
24
|
+
Commands are independent and stateless; the only thing an agent threads between
|
|
25
|
+
steps is the handle.
|
|
26
|
+
|
|
27
|
+
## Commands and JSON results
|
|
28
|
+
|
|
29
|
+
- `login` → `{ "account": str, "self": { "public_identifier", "urn", "full_name" } }`
|
|
30
|
+
- `whoami` → `{ "self": { "public_identifier", "urn", "full_name" } }`
|
|
31
|
+
- `search <keywords> [--network first|second|third] [--page N]` →
|
|
32
|
+
`{ "query", "page", "network": [..], "profiles": [ { "public_identifier", "url" } ] }`
|
|
33
|
+
- `profile <id> [--raw]` → full LinkedIn profile:
|
|
34
|
+
`{ "url", "urn", "public_identifier", "full_name", "headline", "summary",
|
|
35
|
+
"location_name", "positions": [..], "educations": [..], "connection_degree" }`
|
|
36
|
+
- `status <id>` → `{ "public_identifier", "state": "Connected"|"Pending"|"Qualified" }`
|
|
37
|
+
- `connect <id>` → `{ "public_identifier", "state": "Pending"|"Qualified" }` (no note)
|
|
38
|
+
- `message <id> --text <msg>` → `{ "public_identifier", "sent": bool }`
|
|
39
|
+
- `thread <id>` → `{ "public_identifier", "messages": [ { "sender", "text", "timestamp" } ] }`
|
|
40
|
+
|
|
41
|
+
## Errors
|
|
42
|
+
|
|
43
|
+
On failure: non-zero exit and a line on stderr `error: <type>: <message>`.
|
|
44
|
+
Stable `type` values to branch on: `checkpoint_challenge`, `authentication`,
|
|
45
|
+
`profile_inaccessible`, `skip_profile`, `connection_limit`.
|
|
46
|
+
|
|
47
|
+
## Typical agent loop
|
|
48
|
+
|
|
49
|
+
search → for each handle: profile and/or status → message and/or thread.
|
|
50
|
+
|
|
51
|
+
## More
|
|
52
|
+
|
|
53
|
+
- README: https://github.com/eracle/linkedin-cli/blob/main/README.md
|
|
54
|
+
- Per-verb help: `linkedin-cli <verb> --help`
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "linkedin-agent-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Django-free library and CLI for LinkedIn platform mechanics over a bound browser session (Voyager API + Playwright)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "eracle", email = "eracle@posteo.eu" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"linkedin", "linkedin-api", "linkedin-automation", "linkedin-scraper",
|
|
15
|
+
"voyager", "playwright", "browser-automation", "web-scraping", "cli",
|
|
16
|
+
"outreach", "lead-generation", "ai-agent", "llm", "agent-tools",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
|
25
|
+
"Topic :: Office/Business",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"playwright>=1.59",
|
|
30
|
+
"playwright-stealth",
|
|
31
|
+
"tenacity>=8,<10",
|
|
32
|
+
"termcolor",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/eracle/linkedin-cli"
|
|
37
|
+
Repository = "https://github.com/eracle/linkedin-cli"
|
|
38
|
+
Issues = "https://github.com/eracle/linkedin-cli/issues"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
linkedin-cli = "linkedin_cli.cli:main"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/linkedin_cli"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""linkedin_cli — standalone, Django-free LinkedIn interaction library.
|
|
2
|
+
|
|
3
|
+
Owns the LinkedIn *platform* mechanics — navigation, login, the Voyager API
|
|
4
|
+
client, profile/conversation scraping, and the connect/message/status/thread
|
|
5
|
+
verbs. It holds no database and no campaign/CRM context: every verb runs
|
|
6
|
+
against a browser session supplied by the caller (see ``session.LinkedInSession``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# linkedin/actions/connect.py
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
|
|
5
|
+
from linkedin_cli.enums import ProfileState
|
|
6
|
+
from linkedin_cli.exceptions import SkipProfile, ReachedConnectionLimit
|
|
7
|
+
from linkedin_cli.browser.nav import find_top_card, dump_page_html
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
SELECTORS = {
|
|
12
|
+
"weekly_limit": 'div[class*="ip-fuse-limit-alert__warning"]',
|
|
13
|
+
"invite_to_connect": (
|
|
14
|
+
'[aria-label*="Invite"][aria-label*="to connect"]:visible, '
|
|
15
|
+
'a:has(span:text-is("Connect")):visible, '
|
|
16
|
+
'button:has(span:text-is("Connect")):visible'
|
|
17
|
+
),
|
|
18
|
+
"error_toast": 'div[data-test-artdeco-toast-item-type="error"]',
|
|
19
|
+
"more_button": (
|
|
20
|
+
'button[aria-label="More"]:visible, '
|
|
21
|
+
'button[id*="overflow"]:visible, '
|
|
22
|
+
'button[aria-label*="More actions"]:visible, '
|
|
23
|
+
'button:has(span:text-is("More")):visible'
|
|
24
|
+
),
|
|
25
|
+
"connect_option": (
|
|
26
|
+
'div[role="button"][aria-label^="Invite"][aria-label*=" to connect"], '
|
|
27
|
+
'div[role="button"]:text-is("Connect"), '
|
|
28
|
+
'[role="menuitem"][aria-label*="Connect"], '
|
|
29
|
+
'[role="menuitem"]:has-text("Connect"), '
|
|
30
|
+
'li:text-is("Connect"), '
|
|
31
|
+
'span[role="button"]:text-is("Connect")'
|
|
32
|
+
),
|
|
33
|
+
"send_now": 'button:has-text("Send now"), button[aria-label*="Send without"], button[aria-label*="Send invitation"]',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def send_connection_request(
|
|
38
|
+
session: "LinkedInSession",
|
|
39
|
+
profile: Dict[str, Any],
|
|
40
|
+
) -> ProfileState:
|
|
41
|
+
"""
|
|
42
|
+
Sends a LinkedIn connection request WITHOUT a note (fastest & safest).
|
|
43
|
+
|
|
44
|
+
Assumes the profile page is already loaded (caller navigates via
|
|
45
|
+
``get_connection_status`` or ``visit_profile`` beforehand).
|
|
46
|
+
"""
|
|
47
|
+
public_identifier = profile.get('public_identifier')
|
|
48
|
+
|
|
49
|
+
# Send invitation WITHOUT note (current active flow)
|
|
50
|
+
if not _connect_direct(session) and not _connect_via_more(session):
|
|
51
|
+
logger.debug("Connect button not found for %s — staying at current stage", public_identifier)
|
|
52
|
+
dump_page_html(session, profile)
|
|
53
|
+
return ProfileState.QUALIFIED
|
|
54
|
+
|
|
55
|
+
_click_without_note(session)
|
|
56
|
+
_check_weekly_invitation_limit(session)
|
|
57
|
+
|
|
58
|
+
logger.debug("Connection request submitted for %s", public_identifier)
|
|
59
|
+
return ProfileState.PENDING
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_weekly_invitation_limit(session):
|
|
63
|
+
weekly_invitation_limit = session.page.locator(SELECTORS["weekly_limit"])
|
|
64
|
+
if weekly_invitation_limit.count() > 0:
|
|
65
|
+
raise ReachedConnectionLimit("Weekly connection limit pop up appeared")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _connect_direct(session):
|
|
69
|
+
session.wait()
|
|
70
|
+
top_card = find_top_card(session)
|
|
71
|
+
direct = top_card.locator(SELECTORS["invite_to_connect"])
|
|
72
|
+
if direct.count() == 0:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
direct.first.click()
|
|
76
|
+
logger.debug("Clicked direct 'Connect' button")
|
|
77
|
+
|
|
78
|
+
error = session.page.locator(SELECTORS["error_toast"])
|
|
79
|
+
if error.count() > 0:
|
|
80
|
+
raise SkipProfile(f"{error.inner_text().strip()}")
|
|
81
|
+
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _connect_via_more(session):
|
|
86
|
+
session.wait()
|
|
87
|
+
top_card = find_top_card(session)
|
|
88
|
+
page = session.page
|
|
89
|
+
|
|
90
|
+
# Dropdown may render as a portal outside top_card, so search page-wide
|
|
91
|
+
connect_option = page.locator(SELECTORS["connect_option"])
|
|
92
|
+
|
|
93
|
+
# Connect option may already be visible (More dropdown opened by status check)
|
|
94
|
+
if connect_option.count() == 0:
|
|
95
|
+
more = top_card.locator(SELECTORS["more_button"])
|
|
96
|
+
if more.count() == 0:
|
|
97
|
+
return False
|
|
98
|
+
more.first.click()
|
|
99
|
+
session.wait()
|
|
100
|
+
|
|
101
|
+
connect_option = page.locator(SELECTORS["connect_option"])
|
|
102
|
+
if connect_option.count() == 0:
|
|
103
|
+
return False
|
|
104
|
+
connect_option.first.click()
|
|
105
|
+
logger.debug("Used 'More → Connect' flow")
|
|
106
|
+
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _click_without_note(session):
|
|
111
|
+
"""Click flow: sends connection request instantly without note."""
|
|
112
|
+
session.wait()
|
|
113
|
+
|
|
114
|
+
# Click "Send now" / "Send without a note"
|
|
115
|
+
send_btn = session.page.locator(SELECTORS["send_now"])
|
|
116
|
+
send_btn.first.click(force=True)
|
|
117
|
+
session.wait()
|
|
118
|
+
logger.debug("Connection request submitted (no note)")
|