@melaya/runner 1.0.15 → 1.0.18
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.
- package/dist/connection.js +130 -0
- package/dist/linkedinLogin.d.ts +65 -0
- package/dist/linkedinLogin.js +243 -0
- package/dist/linkedinProxy.d.ts +51 -0
- package/dist/linkedinProxy.js +216 -0
- package/dist/linkedinSetup.d.ts +13 -0
- package/dist/linkedinSetup.js +111 -0
- package/package.json +3 -2
package/dist/connection.js
CHANGED
|
@@ -154,6 +154,40 @@ export async function connect(opts) {
|
|
|
154
154
|
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
155
155
|
}
|
|
156
156
|
});
|
|
157
|
+
// ── LinkedIn cookie capture (one-time) ────────────────────────────
|
|
158
|
+
// When the user clicks "Connect LinkedIn" in the platform UI, the
|
|
159
|
+
// server emits this event. We launch a controlled Chromium window on
|
|
160
|
+
// the user's machine, they sign in normally, and we read the resulting
|
|
161
|
+
// li_at + JSESSIONID cookies from the browser context. The cookies
|
|
162
|
+
// travel back to the server as `linkedin:login-result` and get stored
|
|
163
|
+
// envelope-encrypted in agents.credentials — identical to every other
|
|
164
|
+
// connector. After that, the LinkedIn pipelines (auto-like / auto-reply
|
|
165
|
+
// / auto-connect) just read LINKEDIN_LI_AT / LINKEDIN_JSESSIONID from
|
|
166
|
+
// env at run time and call Voyager directly with aiohttp.
|
|
167
|
+
socket.on("linkedin:start-login", async (req) => {
|
|
168
|
+
const sid = req?.session_id;
|
|
169
|
+
if (!sid)
|
|
170
|
+
return;
|
|
171
|
+
console.log(chalk.hex("#0A66C2")(`\n ▶ LinkedIn login requested (session ${sid.slice(0, 8)}…)`));
|
|
172
|
+
const emitProgress = (msg) => {
|
|
173
|
+
socket.emit("linkedin:login-progress", { session_id: sid, message: msg });
|
|
174
|
+
if (opts.verbose)
|
|
175
|
+
console.log(chalk.gray(` [linkedin] ${msg}`));
|
|
176
|
+
};
|
|
177
|
+
try {
|
|
178
|
+
const { runLinkedInLoginFlow, describeOutcome } = await import("./linkedinLogin.js");
|
|
179
|
+
const outcome = await runLinkedInLoginFlow(emitProgress);
|
|
180
|
+
console.log(describeOutcome(outcome));
|
|
181
|
+
socket.emit("linkedin:login-result", { session_id: sid, ...outcome });
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
const msg = e?.message || "linkedin_login_unhandled_error";
|
|
185
|
+
console.log(chalk.red(` ✗ LinkedIn login failed: ${msg}`));
|
|
186
|
+
socket.emit("linkedin:login-result", {
|
|
187
|
+
session_id: sid, ok: false, error: "login_unhandled_error", detail: msg,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
});
|
|
157
191
|
// ── Kill command ───────────────────────────────────────────────────
|
|
158
192
|
socket.on("runner:kill", (data) => {
|
|
159
193
|
const proc = activeProcesses.get(data.runId);
|
|
@@ -163,6 +197,102 @@ export async function connect(opts) {
|
|
|
163
197
|
console.log(chalk.yellow(` ■ Killed run ${data.runId.slice(0, 10)}...`));
|
|
164
198
|
}
|
|
165
199
|
});
|
|
200
|
+
// ── LLM stream relay ───────────────────────────────────────────────
|
|
201
|
+
//
|
|
202
|
+
// The cloud server picked the user's local Ollama / LM Studio model
|
|
203
|
+
// for an AI feature (Build-with-AI architect, etc.) — but the LLM
|
|
204
|
+
// server only exists on this machine, so we proxy the chat-completion
|
|
205
|
+
// here: fetch the local URL, stream chunks back over the existing
|
|
206
|
+
// socket. The server-side `streamLLMViaRunner()` consumes them.
|
|
207
|
+
//
|
|
208
|
+
// Protocol:
|
|
209
|
+
// server → runner: runner:llm-stream-request { reqId, url, headers, body }
|
|
210
|
+
// runner → server: runner:llm-stream-chunk { reqId, chunk: string }
|
|
211
|
+
// runner → server: runner:llm-stream-end { reqId, status }
|
|
212
|
+
// runner → server: runner:llm-stream-error { reqId, error }
|
|
213
|
+
// server → runner: runner:llm-stream-cancel { reqId }
|
|
214
|
+
const _activeLLMStreams = new Map();
|
|
215
|
+
socket.on("runner:llm-stream-request", async (req) => {
|
|
216
|
+
const reqId = req?.reqId;
|
|
217
|
+
if (!reqId || typeof req.url !== "string")
|
|
218
|
+
return;
|
|
219
|
+
// Defense-in-depth: only allow loopback URLs. The server already
|
|
220
|
+
// generates these as `http://localhost:1234` / `http://localhost:11434`,
|
|
221
|
+
// but enforcing it here means a compromised server connection can't
|
|
222
|
+
// pivot the runner into making outbound requests on its behalf.
|
|
223
|
+
let parsed;
|
|
224
|
+
try {
|
|
225
|
+
parsed = new URL(req.url);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
socket.emit("runner:llm-stream-error", { reqId, error: "invalid_url" });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "::1") {
|
|
232
|
+
socket.emit("runner:llm-stream-error", { reqId, error: "non_loopback_url_rejected" });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (opts.verbose)
|
|
236
|
+
console.log(chalk.gray(` [llm-relay] ${reqId.slice(0, 12)} → ${req.url}`));
|
|
237
|
+
const ac = new AbortController();
|
|
238
|
+
_activeLLMStreams.set(reqId, ac);
|
|
239
|
+
try {
|
|
240
|
+
const upstream = await fetch(req.url, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: req.headers || { "content-type": "application/json" },
|
|
243
|
+
body: req.body || "",
|
|
244
|
+
signal: ac.signal,
|
|
245
|
+
});
|
|
246
|
+
if (!upstream.body) {
|
|
247
|
+
const text = await upstream.text().catch(() => "");
|
|
248
|
+
socket.emit("runner:llm-stream-error", {
|
|
249
|
+
reqId,
|
|
250
|
+
error: `upstream_no_body status=${upstream.status} ${text.slice(0, 200)}`,
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// Pipe chunks back as decoded text. The cloud server's parser is
|
|
255
|
+
// text-oriented (SSE `data:` lines) so we decode here and ship
|
|
256
|
+
// strings rather than base64 — keeps the WS payload small + lets
|
|
257
|
+
// the server keep its existing line-buffer code path.
|
|
258
|
+
const reader = upstream.body.getReader();
|
|
259
|
+
const decoder = new TextDecoder();
|
|
260
|
+
while (true) {
|
|
261
|
+
const { value, done } = await reader.read();
|
|
262
|
+
if (done)
|
|
263
|
+
break;
|
|
264
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
265
|
+
if (chunk.length > 0) {
|
|
266
|
+
socket.emit("runner:llm-stream-chunk", { reqId, chunk });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Flush any tail bytes.
|
|
270
|
+
const tail = decoder.decode();
|
|
271
|
+
if (tail.length > 0)
|
|
272
|
+
socket.emit("runner:llm-stream-chunk", { reqId, chunk: tail });
|
|
273
|
+
socket.emit("runner:llm-stream-end", { reqId, status: upstream.status });
|
|
274
|
+
if (opts.verbose)
|
|
275
|
+
console.log(chalk.gray(` [llm-relay] ${reqId.slice(0, 12)} ◄ end ${upstream.status}`));
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
if (e?.name === "AbortError") {
|
|
279
|
+
socket.emit("runner:llm-stream-error", { reqId, error: "aborted" });
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
socket.emit("runner:llm-stream-error", { reqId, error: String(e?.message || e) });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
_activeLLMStreams.delete(reqId);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
socket.on("runner:llm-stream-cancel", (req) => {
|
|
290
|
+
const ac = _activeLLMStreams.get(req?.reqId);
|
|
291
|
+
if (ac) {
|
|
292
|
+
ac.abort();
|
|
293
|
+
_activeLLMStreams.delete(req.reqId);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
166
296
|
// ── Graceful shutdown ──────────────────────────────────────────────
|
|
167
297
|
const cleanup = () => {
|
|
168
298
|
console.log(chalk.gray("\n Shutting down..."));
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn cookie capture via a controlled Chromium window.
|
|
3
|
+
*
|
|
4
|
+
* Flow
|
|
5
|
+
* ----
|
|
6
|
+
* 1. The Melaya server emits `linkedin:start-login` to this runner over
|
|
7
|
+
* Socket.IO when the user clicks "Connect LinkedIn" in the platform UI.
|
|
8
|
+
* 2. We launch a HEADED Chromium window pointed at
|
|
9
|
+
* https://www.linkedin.com/login. The user signs in normally — email,
|
|
10
|
+
* password, any 2FA challenge — exactly like signing in in their
|
|
11
|
+
* everyday browser. Their password never touches the runner or the
|
|
12
|
+
* Melaya server: it goes from their keyboard straight to LinkedIn's
|
|
13
|
+
* own login form.
|
|
14
|
+
* 3. We poll the page URL. As soon as it lands on a logged-in LinkedIn
|
|
15
|
+
* page (`/feed/*`, `/in/*`, `/mynetwork/*`, …) we read the
|
|
16
|
+
* `li_at` and `JSESSIONID` cookies from the browser context.
|
|
17
|
+
* 4. The cookies are returned over Socket.IO as `linkedin:login-result`.
|
|
18
|
+
* The Melaya server stores them envelope-encrypted in
|
|
19
|
+
* `agents.credentials` for that user, exactly like every other
|
|
20
|
+
* connector credential. The browser window auto-closes.
|
|
21
|
+
*
|
|
22
|
+
* Lazy chromium install
|
|
23
|
+
* ---------------------
|
|
24
|
+
* We don't ship Chromium with the runner package (saves ~140 MB on
|
|
25
|
+
* `npx @melaya/runner` first run). The first time a user clicks
|
|
26
|
+
* "Connect LinkedIn", we shell out to `npx playwright install chromium`
|
|
27
|
+
* and stream the install progress back to the UI so they see something
|
|
28
|
+
* happening. Subsequent runs reuse the cached browser.
|
|
29
|
+
*
|
|
30
|
+
* Why a real browser at all
|
|
31
|
+
* -------------------------
|
|
32
|
+
* LinkedIn's Voyager API (the only LinkedIn surface that exposes likes,
|
|
33
|
+
* replies, and connection requests on personal accounts) is cookie-auth,
|
|
34
|
+
* not OAuth. The cookies can only be obtained by completing a real login
|
|
35
|
+
* that satisfies LinkedIn's CSRF + captcha + 2FA gauntlet. A headed
|
|
36
|
+
* Chromium is the only flow that's both "the user logs in normally" and
|
|
37
|
+
* "no DevTools required." The browser is used ONCE per ~year (or until
|
|
38
|
+
* the session expires) — every subsequent Voyager call is direct
|
|
39
|
+
* aiohttp HTTPS, no browser needed.
|
|
40
|
+
*/
|
|
41
|
+
export interface LinkedInLoginResult {
|
|
42
|
+
ok: true;
|
|
43
|
+
cookies: {
|
|
44
|
+
li_at: string;
|
|
45
|
+
JSESSIONID: string;
|
|
46
|
+
};
|
|
47
|
+
profileName?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface LinkedInLoginError {
|
|
50
|
+
ok: false;
|
|
51
|
+
error: string;
|
|
52
|
+
detail?: string;
|
|
53
|
+
}
|
|
54
|
+
export type LinkedInLoginOutcome = LinkedInLoginResult | LinkedInLoginError;
|
|
55
|
+
interface ProgressEmitter {
|
|
56
|
+
(msg: string): void;
|
|
57
|
+
}
|
|
58
|
+
export declare function runLinkedInLoginFlow(progress: ProgressEmitter): Promise<LinkedInLoginOutcome>;
|
|
59
|
+
/**
|
|
60
|
+
* Pretty-print a one-liner summary of the outcome — used by the runner
|
|
61
|
+
* CLI logger so the operator sees something on stdout while the web UI
|
|
62
|
+
* also reflects the result via the relay.
|
|
63
|
+
*/
|
|
64
|
+
export declare function describeOutcome(outcome: LinkedInLoginOutcome): string;
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn cookie capture via a controlled Chromium window.
|
|
3
|
+
*
|
|
4
|
+
* Flow
|
|
5
|
+
* ----
|
|
6
|
+
* 1. The Melaya server emits `linkedin:start-login` to this runner over
|
|
7
|
+
* Socket.IO when the user clicks "Connect LinkedIn" in the platform UI.
|
|
8
|
+
* 2. We launch a HEADED Chromium window pointed at
|
|
9
|
+
* https://www.linkedin.com/login. The user signs in normally — email,
|
|
10
|
+
* password, any 2FA challenge — exactly like signing in in their
|
|
11
|
+
* everyday browser. Their password never touches the runner or the
|
|
12
|
+
* Melaya server: it goes from their keyboard straight to LinkedIn's
|
|
13
|
+
* own login form.
|
|
14
|
+
* 3. We poll the page URL. As soon as it lands on a logged-in LinkedIn
|
|
15
|
+
* page (`/feed/*`, `/in/*`, `/mynetwork/*`, …) we read the
|
|
16
|
+
* `li_at` and `JSESSIONID` cookies from the browser context.
|
|
17
|
+
* 4. The cookies are returned over Socket.IO as `linkedin:login-result`.
|
|
18
|
+
* The Melaya server stores them envelope-encrypted in
|
|
19
|
+
* `agents.credentials` for that user, exactly like every other
|
|
20
|
+
* connector credential. The browser window auto-closes.
|
|
21
|
+
*
|
|
22
|
+
* Lazy chromium install
|
|
23
|
+
* ---------------------
|
|
24
|
+
* We don't ship Chromium with the runner package (saves ~140 MB on
|
|
25
|
+
* `npx @melaya/runner` first run). The first time a user clicks
|
|
26
|
+
* "Connect LinkedIn", we shell out to `npx playwright install chromium`
|
|
27
|
+
* and stream the install progress back to the UI so they see something
|
|
28
|
+
* happening. Subsequent runs reuse the cached browser.
|
|
29
|
+
*
|
|
30
|
+
* Why a real browser at all
|
|
31
|
+
* -------------------------
|
|
32
|
+
* LinkedIn's Voyager API (the only LinkedIn surface that exposes likes,
|
|
33
|
+
* replies, and connection requests on personal accounts) is cookie-auth,
|
|
34
|
+
* not OAuth. The cookies can only be obtained by completing a real login
|
|
35
|
+
* that satisfies LinkedIn's CSRF + captcha + 2FA gauntlet. A headed
|
|
36
|
+
* Chromium is the only flow that's both "the user logs in normally" and
|
|
37
|
+
* "no DevTools required." The browser is used ONCE per ~year (or until
|
|
38
|
+
* the session expires) — every subsequent Voyager call is direct
|
|
39
|
+
* aiohttp HTTPS, no browser needed.
|
|
40
|
+
*/
|
|
41
|
+
import { spawn } from "child_process";
|
|
42
|
+
import chalk from "chalk";
|
|
43
|
+
const LOGIN_TIMEOUT_MS = 5 * 60_000; // 5 min for the user to finish typing
|
|
44
|
+
const POLL_INTERVAL_MS = 750;
|
|
45
|
+
// URL fragments that indicate "the user is logged in" — LinkedIn redirects
|
|
46
|
+
// to /feed/ after the standard sign-in, but if 2FA was just completed it
|
|
47
|
+
// might land on /checkpoint/lg/ first or /home — accept any logged-in path.
|
|
48
|
+
const LOGGED_IN_PATHS = [
|
|
49
|
+
"/feed/", "/feed",
|
|
50
|
+
"/in/",
|
|
51
|
+
"/mynetwork/",
|
|
52
|
+
"/home/",
|
|
53
|
+
"/notifications/",
|
|
54
|
+
"/messaging/",
|
|
55
|
+
];
|
|
56
|
+
/**
|
|
57
|
+
* Ensure the `playwright` package is importable. We pre-declared it as
|
|
58
|
+
* a dependency in package.json — this is a guard for cases where npm
|
|
59
|
+
* stripped it (corrupt install) or the user is running from a weird env.
|
|
60
|
+
*/
|
|
61
|
+
async function _ensurePlaywrightAvailable(progress) {
|
|
62
|
+
try {
|
|
63
|
+
return await import("playwright");
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
progress("Playwright module not found in node_modules. Re-installing the runner usually fixes this.");
|
|
67
|
+
throw new Error(`playwright_unavailable: ${e?.message || e}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Run `npx playwright install chromium` and stream stdout back via
|
|
72
|
+
* `progress`. Resolves when the child process exits with code 0,
|
|
73
|
+
* rejects otherwise. Idempotent — if Chromium is already installed,
|
|
74
|
+
* Playwright's installer prints a one-line "already installed" and
|
|
75
|
+
* exits 0 in well under a second.
|
|
76
|
+
*/
|
|
77
|
+
function _installChromium(progress) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
progress("Preparing browser for LinkedIn sign-in (one-time setup, ~140 MB)…");
|
|
80
|
+
// On Windows `npx` is a `.cmd` shim — `spawn` handles it correctly when
|
|
81
|
+
// given the bare command name and `shell: true`.
|
|
82
|
+
const child = spawn("npx", ["playwright", "install", "chromium"], {
|
|
83
|
+
shell: true,
|
|
84
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
85
|
+
});
|
|
86
|
+
child.stdout?.on("data", (b) => {
|
|
87
|
+
const line = b.toString().trim();
|
|
88
|
+
if (line)
|
|
89
|
+
progress(line);
|
|
90
|
+
});
|
|
91
|
+
child.stderr?.on("data", (b) => {
|
|
92
|
+
const line = b.toString().trim();
|
|
93
|
+
if (line)
|
|
94
|
+
progress(line);
|
|
95
|
+
});
|
|
96
|
+
child.on("error", (e) => reject(new Error(`chromium_install_error: ${e.message}`)));
|
|
97
|
+
child.on("exit", (code) => {
|
|
98
|
+
if (code === 0) {
|
|
99
|
+
progress("Browser ready.");
|
|
100
|
+
resolve();
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
reject(new Error(`chromium_install_failed: exit code ${code}`));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function _pageLooksLoggedIn(url) {
|
|
109
|
+
try {
|
|
110
|
+
const u = new URL(url);
|
|
111
|
+
if (u.hostname !== "www.linkedin.com" && u.hostname !== "linkedin.com")
|
|
112
|
+
return false;
|
|
113
|
+
return LOGGED_IN_PATHS.some((p) => u.pathname.startsWith(p));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function runLinkedInLoginFlow(progress) {
|
|
120
|
+
// 1. Ensure Playwright + Chromium are available.
|
|
121
|
+
let playwright;
|
|
122
|
+
try {
|
|
123
|
+
playwright = await _ensurePlaywrightAvailable(progress);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
return { ok: false, error: "playwright_unavailable", detail: e?.message };
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await _installChromium(progress);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return { ok: false, error: "chromium_install_failed", detail: e?.message };
|
|
133
|
+
}
|
|
134
|
+
// 2. Launch a headed Chromium window. Use a fresh context (no profile
|
|
135
|
+
// reuse) so the user always sees a clean LinkedIn login page even
|
|
136
|
+
// if they have an old / wrong account session lying around.
|
|
137
|
+
let browser = null;
|
|
138
|
+
try {
|
|
139
|
+
progress("Opening LinkedIn login window…");
|
|
140
|
+
browser = await playwright.chromium.launch({
|
|
141
|
+
headless: false,
|
|
142
|
+
args: [
|
|
143
|
+
"--disable-blink-features=AutomationControlled",
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
const context = await browser.newContext({
|
|
147
|
+
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
148
|
+
"(KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
|
|
149
|
+
viewport: { width: 1280, height: 820 },
|
|
150
|
+
});
|
|
151
|
+
const page = await context.newPage();
|
|
152
|
+
await page.goto("https://www.linkedin.com/login", { waitUntil: "domcontentloaded" });
|
|
153
|
+
progress("Sign in with your LinkedIn account in the window that just opened.");
|
|
154
|
+
// 3. Wait for the user to finish signing in. Two terminal conditions:
|
|
155
|
+
// (a) URL becomes a logged-in LinkedIn path → success.
|
|
156
|
+
// (b) The user closes the window manually → cancellation.
|
|
157
|
+
// A 5-minute total timeout protects against the user walking away.
|
|
158
|
+
const start = Date.now();
|
|
159
|
+
let succeeded = false;
|
|
160
|
+
while (Date.now() - start < LOGIN_TIMEOUT_MS) {
|
|
161
|
+
// browser closed externally → context.pages() throws.
|
|
162
|
+
let url;
|
|
163
|
+
try {
|
|
164
|
+
url = page.url();
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return { ok: false, error: "user_cancelled", detail: "Login window was closed before sign-in completed." };
|
|
168
|
+
}
|
|
169
|
+
if (_pageLooksLoggedIn(url)) {
|
|
170
|
+
succeeded = true;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
174
|
+
}
|
|
175
|
+
if (!succeeded) {
|
|
176
|
+
return { ok: false, error: "login_timeout", detail: "5-minute sign-in window expired." };
|
|
177
|
+
}
|
|
178
|
+
// 4. Extract cookies. Voyager auth = `li_at` + `JSESSIONID`.
|
|
179
|
+
const cookies = await context.cookies("https://www.linkedin.com");
|
|
180
|
+
const li_at_c = cookies.find((c) => c.name === "li_at");
|
|
181
|
+
const js_c = cookies.find((c) => c.name === "JSESSIONID");
|
|
182
|
+
if (!li_at_c?.value || !js_c?.value) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false, error: "cookies_not_found",
|
|
185
|
+
detail: "Sign-in completed but li_at / JSESSIONID cookies were missing. Try again and complete the LinkedIn 2FA challenge if prompted.",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const li_at = li_at_c.value;
|
|
189
|
+
const JSESSIONID = js_c.value;
|
|
190
|
+
// 5. Optional pretty touch: pull the operator's display name so the
|
|
191
|
+
// UI can show "Connected as Antoine Roche". Use the now-logged-in
|
|
192
|
+
// page to evaluate window.__INITIAL_SHARED_DATA__ — best effort,
|
|
193
|
+
// failures are non-fatal.
|
|
194
|
+
let profileName;
|
|
195
|
+
try {
|
|
196
|
+
profileName = await page.evaluate(() => {
|
|
197
|
+
// The /feed page exposes the user's name in several places. Probe a few
|
|
198
|
+
// reliable selectors before falling back to undefined.
|
|
199
|
+
const sel = (s) => {
|
|
200
|
+
const el = document.querySelector(s);
|
|
201
|
+
return el ? (el.textContent || "").trim() : "";
|
|
202
|
+
};
|
|
203
|
+
return (sel(".global-nav__me .global-nav__primary-link-text")
|
|
204
|
+
|| sel(".feed-identity-module__actor-meta a")
|
|
205
|
+
|| sel("a[data-test-app-aware-link] .feed-identity-module__actor-meta")
|
|
206
|
+
|| undefined);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch { /* swallow */ }
|
|
210
|
+
progress(profileName ? `Captured session for ${profileName}.` : "Captured session.");
|
|
211
|
+
// 6. Close the window — we're done with the browser. No need to keep
|
|
212
|
+
// it open: every subsequent Voyager call uses the cookies via
|
|
213
|
+
// direct aiohttp, not Playwright.
|
|
214
|
+
await context.close();
|
|
215
|
+
await browser.close();
|
|
216
|
+
return { ok: true, cookies: { li_at, JSESSIONID }, profileName };
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false, error: "login_flow_error",
|
|
221
|
+
detail: e?.message || String(e),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
if (browser) {
|
|
226
|
+
try {
|
|
227
|
+
await browser.close();
|
|
228
|
+
}
|
|
229
|
+
catch { /* already closed */ }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Pretty-print a one-liner summary of the outcome — used by the runner
|
|
235
|
+
* CLI logger so the operator sees something on stdout while the web UI
|
|
236
|
+
* also reflects the result via the relay.
|
|
237
|
+
*/
|
|
238
|
+
export function describeOutcome(outcome) {
|
|
239
|
+
if (outcome.ok) {
|
|
240
|
+
return chalk.green(` ✓ LinkedIn session captured${outcome.profileName ? ` (${outcome.profileName})` : ""}.`);
|
|
241
|
+
}
|
|
242
|
+
return chalk.red(` ✗ LinkedIn login: ${outcome.error}${outcome.detail ? ` — ${outcome.detail}` : ""}`);
|
|
243
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn Voyager-API egress for the @melaya/runner.
|
|
3
|
+
*
|
|
4
|
+
* The server (Alibaba) forwards LinkedIn API calls to the runner via
|
|
5
|
+
* Socket.IO `linkedin:exec` events. This module:
|
|
6
|
+
*
|
|
7
|
+
* 1. Reads the operator's local cookies (li_at + JSESSIONID) from
|
|
8
|
+
* ~/.melaya-runner/linkedin/cookies.json. The cookies were saved
|
|
9
|
+
* via `npx @melaya/runner linkedin-setup`. They never leave this
|
|
10
|
+
* machine.
|
|
11
|
+
*
|
|
12
|
+
* 2. Issues the actual HTTPS call to LinkedIn's Voyager API
|
|
13
|
+
* (https://www.linkedin.com/voyager/api/...) using Node's native
|
|
14
|
+
* fetch. The egress IP is the operator's residential IP — exactly
|
|
15
|
+
* what they normally browse LinkedIn from, so no automation
|
|
16
|
+
* flags.
|
|
17
|
+
*
|
|
18
|
+
* 3. For "write" actions (likes / replies / connection requests)
|
|
19
|
+
* inserts a 3-9s human pause before the call, increments a daily
|
|
20
|
+
* counter at ~/.melaya-runner/linkedin/usage-{YYYY-MM-DD}.json,
|
|
21
|
+
* and refuses with `daily_cap_reached` past the configured cap.
|
|
22
|
+
*
|
|
23
|
+
* 4. Returns { status, body } back to the server so the caller (the
|
|
24
|
+
* Python agent's social_linkedin tool) can parse the response.
|
|
25
|
+
*
|
|
26
|
+
* Caps (override via env on the runner):
|
|
27
|
+
* LINKEDIN_DAILY_LIKES_CAP default 50
|
|
28
|
+
* LINKEDIN_DAILY_REPLIES_CAP default 20
|
|
29
|
+
* LINKEDIN_DAILY_CONNECTIONS_CAP default 15
|
|
30
|
+
*/
|
|
31
|
+
interface LinkedInCookies {
|
|
32
|
+
li_at: string;
|
|
33
|
+
JSESSIONID: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function readCookies(): LinkedInCookies | null;
|
|
36
|
+
export declare function writeCookies(c: LinkedInCookies): string;
|
|
37
|
+
export interface LinkedInExecRequest {
|
|
38
|
+
correlation_id: string;
|
|
39
|
+
method: string;
|
|
40
|
+
path: string;
|
|
41
|
+
params?: Record<string, unknown> | null;
|
|
42
|
+
body?: unknown;
|
|
43
|
+
write?: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface LinkedInExecResult {
|
|
46
|
+
status?: number;
|
|
47
|
+
body?: unknown;
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function handleLinkedInExec(req: LinkedInExecRequest, verbose?: boolean): Promise<LinkedInExecResult>;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn Voyager-API egress for the @melaya/runner.
|
|
3
|
+
*
|
|
4
|
+
* The server (Alibaba) forwards LinkedIn API calls to the runner via
|
|
5
|
+
* Socket.IO `linkedin:exec` events. This module:
|
|
6
|
+
*
|
|
7
|
+
* 1. Reads the operator's local cookies (li_at + JSESSIONID) from
|
|
8
|
+
* ~/.melaya-runner/linkedin/cookies.json. The cookies were saved
|
|
9
|
+
* via `npx @melaya/runner linkedin-setup`. They never leave this
|
|
10
|
+
* machine.
|
|
11
|
+
*
|
|
12
|
+
* 2. Issues the actual HTTPS call to LinkedIn's Voyager API
|
|
13
|
+
* (https://www.linkedin.com/voyager/api/...) using Node's native
|
|
14
|
+
* fetch. The egress IP is the operator's residential IP — exactly
|
|
15
|
+
* what they normally browse LinkedIn from, so no automation
|
|
16
|
+
* flags.
|
|
17
|
+
*
|
|
18
|
+
* 3. For "write" actions (likes / replies / connection requests)
|
|
19
|
+
* inserts a 3-9s human pause before the call, increments a daily
|
|
20
|
+
* counter at ~/.melaya-runner/linkedin/usage-{YYYY-MM-DD}.json,
|
|
21
|
+
* and refuses with `daily_cap_reached` past the configured cap.
|
|
22
|
+
*
|
|
23
|
+
* 4. Returns { status, body } back to the server so the caller (the
|
|
24
|
+
* Python agent's social_linkedin tool) can parse the response.
|
|
25
|
+
*
|
|
26
|
+
* Caps (override via env on the runner):
|
|
27
|
+
* LINKEDIN_DAILY_LIKES_CAP default 50
|
|
28
|
+
* LINKEDIN_DAILY_REPLIES_CAP default 20
|
|
29
|
+
* LINKEDIN_DAILY_CONNECTIONS_CAP default 15
|
|
30
|
+
*/
|
|
31
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "fs";
|
|
32
|
+
import { homedir } from "os";
|
|
33
|
+
import { join } from "path";
|
|
34
|
+
import chalk from "chalk";
|
|
35
|
+
const VOYAGER_BASE = "https://www.linkedin.com/voyager/api";
|
|
36
|
+
const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
|
|
37
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
|
38
|
+
"Chrome/127.0.0.0 Safari/537.36";
|
|
39
|
+
// ── Storage paths ─────────────────────────────────────────────────────────────
|
|
40
|
+
function stateDir() {
|
|
41
|
+
return process.env.MELAYA_LINKEDIN_DIR || join(homedir(), ".melaya-runner", "linkedin");
|
|
42
|
+
}
|
|
43
|
+
function cookiesPath() {
|
|
44
|
+
return join(stateDir(), "cookies.json");
|
|
45
|
+
}
|
|
46
|
+
function usagePath() {
|
|
47
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
48
|
+
return join(stateDir(), `usage-${day}.json`);
|
|
49
|
+
}
|
|
50
|
+
// ── Caps ──────────────────────────────────────────────────────────────────────
|
|
51
|
+
function envCap(name, fallback) {
|
|
52
|
+
const raw = process.env[name];
|
|
53
|
+
if (!raw)
|
|
54
|
+
return fallback;
|
|
55
|
+
const n = parseInt(raw, 10);
|
|
56
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
57
|
+
}
|
|
58
|
+
const CAP_LIKES = envCap("LINKEDIN_DAILY_LIKES_CAP", 50);
|
|
59
|
+
const CAP_REPLIES = envCap("LINKEDIN_DAILY_REPLIES_CAP", 20);
|
|
60
|
+
const CAP_CONNECTIONS = envCap("LINKEDIN_DAILY_CONNECTIONS_CAP", 15);
|
|
61
|
+
function readUsage() {
|
|
62
|
+
const p = usagePath();
|
|
63
|
+
if (!existsSync(p))
|
|
64
|
+
return {};
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function bumpUsage(action) {
|
|
73
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
74
|
+
const counts = readUsage();
|
|
75
|
+
counts[action] = (counts[action] || 0) + 1;
|
|
76
|
+
writeFileSync(usagePath(), JSON.stringify(counts));
|
|
77
|
+
return counts[action];
|
|
78
|
+
}
|
|
79
|
+
export function readCookies() {
|
|
80
|
+
const p = cookiesPath();
|
|
81
|
+
if (!existsSync(p))
|
|
82
|
+
return null;
|
|
83
|
+
try {
|
|
84
|
+
const raw = JSON.parse(readFileSync(p, "utf-8"));
|
|
85
|
+
if (raw && typeof raw.li_at === "string" && typeof raw.JSESSIONID === "string"
|
|
86
|
+
&& raw.li_at.length > 10 && raw.JSESSIONID.length > 10) {
|
|
87
|
+
return raw;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch { /* corrupt file */ }
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
export function writeCookies(c) {
|
|
94
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
95
|
+
const p = cookiesPath();
|
|
96
|
+
writeFileSync(p, JSON.stringify(c, null, 2), "utf-8");
|
|
97
|
+
// Lock down on POSIX. On Windows the user's profile dir ACL already
|
|
98
|
+
// restricts read access to the user.
|
|
99
|
+
try {
|
|
100
|
+
chmodSync(p, 0o600);
|
|
101
|
+
}
|
|
102
|
+
catch { /* ignore Windows */ }
|
|
103
|
+
return p;
|
|
104
|
+
}
|
|
105
|
+
function csrfToken(jsessionid) {
|
|
106
|
+
// Voyager expects the JSESSIONID value with quotes stripped.
|
|
107
|
+
return jsessionid.replace(/^"+|"+$/g, "");
|
|
108
|
+
}
|
|
109
|
+
// ── Action classification (for cap + pause) ──────────────────────────────────
|
|
110
|
+
function classifyAction(method, path) {
|
|
111
|
+
const m = method.toUpperCase();
|
|
112
|
+
if (m === "GET")
|
|
113
|
+
return "read";
|
|
114
|
+
// Best-effort classifier — the server already passes `write: true`
|
|
115
|
+
// for actions that should be paused/capped, so this only fires when
|
|
116
|
+
// the server didn't tag it.
|
|
117
|
+
if (path.includes("/feed/reactions"))
|
|
118
|
+
return "like";
|
|
119
|
+
if (path.includes("/feed/comments"))
|
|
120
|
+
return "reply";
|
|
121
|
+
if (path.includes("/voyagerRelationshipsDashMemberRelationships"))
|
|
122
|
+
return "connect";
|
|
123
|
+
return "read";
|
|
124
|
+
}
|
|
125
|
+
function capForAction(action) {
|
|
126
|
+
const u = readUsage();
|
|
127
|
+
switch (action) {
|
|
128
|
+
case "like": return { used: u.likes || 0, cap: CAP_LIKES };
|
|
129
|
+
case "reply": return { used: u.replies || 0, cap: CAP_REPLIES };
|
|
130
|
+
case "connect": return { used: u.connections || 0, cap: CAP_CONNECTIONS };
|
|
131
|
+
default: return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function bumpForAction(action) {
|
|
135
|
+
switch (action) {
|
|
136
|
+
case "like": return bumpUsage("likes");
|
|
137
|
+
case "reply": return bumpUsage("replies");
|
|
138
|
+
case "connect": return bumpUsage("connections");
|
|
139
|
+
default: return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function humanPause() {
|
|
143
|
+
const ms = 3000 + Math.floor(Math.random() * 6000);
|
|
144
|
+
await new Promise((res) => setTimeout(res, ms));
|
|
145
|
+
}
|
|
146
|
+
export async function handleLinkedInExec(req, verbose = false) {
|
|
147
|
+
const cookies = readCookies();
|
|
148
|
+
if (!cookies) {
|
|
149
|
+
return { error: "cookies_missing — run `npx @melaya/runner linkedin-setup` once on this machine" };
|
|
150
|
+
}
|
|
151
|
+
const action = classifyAction(req.method, req.path);
|
|
152
|
+
const isWrite = !!req.write || action !== "read";
|
|
153
|
+
if (isWrite && action !== "read") {
|
|
154
|
+
const c = capForAction(action);
|
|
155
|
+
if (c && c.used >= c.cap) {
|
|
156
|
+
return { error: `daily_cap_reached: ${action} ${c.used}/${c.cap}. Resets 00:00 UTC.` };
|
|
157
|
+
}
|
|
158
|
+
await humanPause();
|
|
159
|
+
}
|
|
160
|
+
const csrf = csrfToken(cookies.JSESSIONID);
|
|
161
|
+
// Build URL with query params (URLSearchParams handles array/object encoding)
|
|
162
|
+
const url = new URL(VOYAGER_BASE + req.path);
|
|
163
|
+
if (req.params && typeof req.params === "object") {
|
|
164
|
+
for (const [k, v] of Object.entries(req.params)) {
|
|
165
|
+
if (v == null)
|
|
166
|
+
continue;
|
|
167
|
+
url.searchParams.append(k, String(v));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const headers = {
|
|
171
|
+
"User-Agent": USER_AGENT,
|
|
172
|
+
"Accept": "application/vnd.linkedin.normalized+json+2.1",
|
|
173
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
174
|
+
"csrf-token": csrf,
|
|
175
|
+
"x-restli-protocol-version": "2.0.0",
|
|
176
|
+
"x-li-lang": "en_US",
|
|
177
|
+
"x-li-track": JSON.stringify({
|
|
178
|
+
clientVersion: "1.13.0", mpVersion: "1.13.0", osName: "web",
|
|
179
|
+
timezoneOffset: -8, timezone: "America/Los_Angeles",
|
|
180
|
+
deviceFormFactor: "DESKTOP", mpName: "voyager-web",
|
|
181
|
+
}),
|
|
182
|
+
"Cookie": `li_at=${cookies.li_at}; JSESSIONID=${cookies.JSESSIONID}`,
|
|
183
|
+
"Referer": "https://www.linkedin.com/feed/",
|
|
184
|
+
"Origin": "https://www.linkedin.com",
|
|
185
|
+
};
|
|
186
|
+
const init = { method: req.method, headers };
|
|
187
|
+
if (req.body !== null && req.body !== undefined) {
|
|
188
|
+
headers["Content-Type"] = "application/json";
|
|
189
|
+
init.body = JSON.stringify(req.body);
|
|
190
|
+
}
|
|
191
|
+
if (verbose) {
|
|
192
|
+
process.stdout.write(chalk.gray(` [linkedin] ${req.method} ${req.path}${isWrite ? " (write)" : ""}\n`));
|
|
193
|
+
}
|
|
194
|
+
let res;
|
|
195
|
+
try {
|
|
196
|
+
res = await fetch(url.toString(), init);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
return { error: `network_error: ${e?.message || e}` };
|
|
200
|
+
}
|
|
201
|
+
const txt = await res.text().catch(() => "");
|
|
202
|
+
let body = txt;
|
|
203
|
+
try {
|
|
204
|
+
body = JSON.parse(txt);
|
|
205
|
+
}
|
|
206
|
+
catch { /* keep as text */ }
|
|
207
|
+
// Bump the counter ONLY on a 2xx — failures don't count toward the
|
|
208
|
+
// daily quota (LinkedIn doesn't count them either).
|
|
209
|
+
if (isWrite && res.ok) {
|
|
210
|
+
bumpForAction(action);
|
|
211
|
+
}
|
|
212
|
+
if (verbose) {
|
|
213
|
+
process.stdout.write(chalk.gray(` [linkedin] → ${res.status}\n`));
|
|
214
|
+
}
|
|
215
|
+
return { status: res.status, body };
|
|
216
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx @melaya/runner linkedin-setup` — interactive cookie onboarding.
|
|
3
|
+
*
|
|
4
|
+
* Walks the operator through extracting their LinkedIn session cookies
|
|
5
|
+
* from a logged-in browser, saves them locally to ~/.melaya-runner/
|
|
6
|
+
* linkedin/cookies.json, and runs a sanity check (`GET /me`) to confirm
|
|
7
|
+
* the session is live.
|
|
8
|
+
*
|
|
9
|
+
* Cookies are stored ONLY on this machine — never transmitted to the
|
|
10
|
+
* Melaya server. The agents on the cloud side reach LinkedIn through
|
|
11
|
+
* a Socket.IO proxy that forwards each call back here for egress.
|
|
12
|
+
*/
|
|
13
|
+
export declare function runLinkedinSetup(): Promise<void>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx @melaya/runner linkedin-setup` — interactive cookie onboarding.
|
|
3
|
+
*
|
|
4
|
+
* Walks the operator through extracting their LinkedIn session cookies
|
|
5
|
+
* from a logged-in browser, saves them locally to ~/.melaya-runner/
|
|
6
|
+
* linkedin/cookies.json, and runs a sanity check (`GET /me`) to confirm
|
|
7
|
+
* the session is live.
|
|
8
|
+
*
|
|
9
|
+
* Cookies are stored ONLY on this machine — never transmitted to the
|
|
10
|
+
* Melaya server. The agents on the cloud side reach LinkedIn through
|
|
11
|
+
* a Socket.IO proxy that forwards each call back here for egress.
|
|
12
|
+
*/
|
|
13
|
+
import chalk from "chalk";
|
|
14
|
+
import { createInterface } from "readline";
|
|
15
|
+
import { writeCookies, handleLinkedInExec } from "./linkedinProxy.js";
|
|
16
|
+
function ask(prompt, hidden = false) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const rl = createInterface({
|
|
19
|
+
input: process.stdin,
|
|
20
|
+
output: process.stdout,
|
|
21
|
+
terminal: !hidden,
|
|
22
|
+
});
|
|
23
|
+
if (hidden) {
|
|
24
|
+
// Mask while typing — best-effort; works in most TTYs. Falls back
|
|
25
|
+
// to plain echo if stdin isn't a TTY.
|
|
26
|
+
const stdout = process.stdout;
|
|
27
|
+
const stdin = process.stdin;
|
|
28
|
+
if (stdin.isTTY) {
|
|
29
|
+
const onKey = (char) => {
|
|
30
|
+
const c = char.toString();
|
|
31
|
+
if (c === "\r" || c === "\n")
|
|
32
|
+
return;
|
|
33
|
+
stdout.write("*");
|
|
34
|
+
};
|
|
35
|
+
stdin.on("data", onKey);
|
|
36
|
+
rl.question(prompt, (answer) => {
|
|
37
|
+
stdin.off("data", onKey);
|
|
38
|
+
process.stdout.write("\n");
|
|
39
|
+
rl.close();
|
|
40
|
+
resolve(answer);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
rl.question(prompt, (answer) => { rl.close(); resolve(answer); });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
rl.question(prompt, (answer) => { rl.close(); resolve(answer); });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const ORANGE = chalk.hex("#FF8C00");
|
|
53
|
+
const CYAN = chalk.hex("#22D3EE");
|
|
54
|
+
const DIM = chalk.gray;
|
|
55
|
+
export async function runLinkedinSetup() {
|
|
56
|
+
console.log();
|
|
57
|
+
console.log(CYAN.bold(" ▸ LinkedIn — cookie setup"));
|
|
58
|
+
console.log(DIM(" ─────────────────────────────────────────────────────"));
|
|
59
|
+
console.log();
|
|
60
|
+
console.log(DIM(" Why: agents in cloud pipelines need to reach LinkedIn through"));
|
|
61
|
+
console.log(DIM(" this machine's residential IP (datacenter IPs get flagged)."));
|
|
62
|
+
console.log(DIM(" Cookies are saved locally only — never sent to the server."));
|
|
63
|
+
console.log();
|
|
64
|
+
console.log(chalk.bold(" Steps:"));
|
|
65
|
+
console.log(` 1. Open ${ORANGE("https://www.linkedin.com")} in a logged-in browser tab`);
|
|
66
|
+
console.log(` 2. Open DevTools ${DIM("(F12 / Cmd-Opt-I)")} → Application → Cookies → linkedin.com`);
|
|
67
|
+
console.log(` 3. Copy the value of ${ORANGE("li_at")} (long base64-ish token)`);
|
|
68
|
+
console.log(` 4. Copy the value of ${ORANGE("JSESSIONID")} (keep the surrounding quotes)`);
|
|
69
|
+
console.log();
|
|
70
|
+
const li_at = (await ask(" li_at: ", true)).trim();
|
|
71
|
+
if (!li_at || li_at.length < 20) {
|
|
72
|
+
console.log(chalk.red("\n ✗ li_at looks invalid (too short). Aborting."));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const JSESSIONID = (await ask(" JSESSIONID: ", true)).trim();
|
|
76
|
+
if (!JSESSIONID || JSESSIONID.length < 5) {
|
|
77
|
+
console.log(chalk.red("\n ✗ JSESSIONID looks invalid. Aborting."));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
const path = writeCookies({ li_at, JSESSIONID });
|
|
81
|
+
console.log(chalk.green(`\n ✓ Cookies saved to ${path}`));
|
|
82
|
+
// Sanity check: hit /me to confirm the session works.
|
|
83
|
+
console.log(DIM(" Verifying session…"));
|
|
84
|
+
const result = await handleLinkedInExec({
|
|
85
|
+
correlation_id: "setup-probe",
|
|
86
|
+
method: "GET",
|
|
87
|
+
path: "/me",
|
|
88
|
+
});
|
|
89
|
+
if (result.error) {
|
|
90
|
+
console.log(chalk.red(` ✗ Verification failed: ${result.error}`));
|
|
91
|
+
console.log(DIM(" Cookies were saved but the test call failed. Try re-pasting from a fresh browser session."));
|
|
92
|
+
process.exit(2);
|
|
93
|
+
}
|
|
94
|
+
if (result.status && result.status >= 200 && result.status < 300) {
|
|
95
|
+
const body = result.body;
|
|
96
|
+
const name = body?.miniProfile?.firstName + " " + body?.miniProfile?.lastName
|
|
97
|
+
|| body?.firstName + " " + body?.lastName
|
|
98
|
+
|| "(name not in response)";
|
|
99
|
+
console.log(chalk.green(` ✓ Connected as: ${name}`));
|
|
100
|
+
console.log();
|
|
101
|
+
console.log(DIM(" You can now run any LinkedIn pipeline from the platform."));
|
|
102
|
+
console.log(DIM(" Start the runner normally with:"));
|
|
103
|
+
console.log(` ${chalk.bold("npx @melaya/runner --token=mel_run_…")}`);
|
|
104
|
+
console.log();
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
console.log(chalk.yellow(` ⚠ Unexpected status ${result.status} during verification`));
|
|
108
|
+
console.log(DIM(` Body preview: ${JSON.stringify(result.body).slice(0, 200)}`));
|
|
109
|
+
console.log(DIM(" Cookies were saved; try a pipeline run to see if it works."));
|
|
110
|
+
}
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@melaya/runner",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.18",
|
|
4
4
|
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"socket.io-client": "^4.8.0",
|
|
23
23
|
"commander": "^12.0.0",
|
|
24
24
|
"chalk": "^5.3.0",
|
|
25
|
-
"ora": "^8.0.0"
|
|
25
|
+
"ora": "^8.0.0",
|
|
26
|
+
"playwright": "^1.47.0"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"typescript": "^5.5.0",
|