@melaya/runner 1.0.65 → 1.0.66

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.
@@ -25,6 +25,7 @@ const __filename = fileURLToPath(import.meta.url);
25
25
  const __dirname = dirname(__filename);
26
26
  import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
27
27
  import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
28
+ import { startLinkedInBrowserBridge } from "./linkedinBrowserBridge.js";
28
29
  const HEARTBEAT_INTERVAL = 30_000;
29
30
  const activeProcesses = new Map();
30
31
  export async function connect(opts) {
@@ -61,6 +62,28 @@ export async function connect(opts) {
61
62
  console.log(chalk.yellow(` ⚠ Luma browser bridge failed to start: ${e?.message || e}`));
62
63
  }
63
64
  };
65
+ // LinkedIn browser bridge — drives a logged-in Playwright session for the
66
+ // SDUI-only surfaces (home feed read, comment read/write, and react/comment
67
+ // AS A PAGE) that the aiohttp path in social_linkedin.py cannot reach. Unlike
68
+ // Luma it needs no saved storage state: the Python tool injects the session
69
+ // cookies (LINKEDIN_LI_AT / LINKEDIN_JSESSIONID) per request, so we boot it
70
+ // unconditionally and let it lazy-launch Chromium on first use.
71
+ let linkedinBridge = null;
72
+ const _ensureLinkedInBridge = async () => {
73
+ if (linkedinBridge)
74
+ return;
75
+ try {
76
+ linkedinBridge = await startLinkedInBrowserBridge({
77
+ log: (m) => {
78
+ if (opts.verbose)
79
+ console.log(chalk.gray(` [linkedin-bridge] ${m}`));
80
+ },
81
+ });
82
+ }
83
+ catch (e) {
84
+ console.log(chalk.yellow(` ⚠ LinkedIn browser bridge failed to start: ${e?.message || e}`));
85
+ }
86
+ };
64
87
  socket.on("connect", async () => {
65
88
  spinner.succeed(chalk.green("Connected to Melaya"));
66
89
  // Report detected models
@@ -76,6 +99,7 @@ export async function connect(opts) {
76
99
  // effort — failure here means Luma POSTs fall back to aiohttp
77
100
  // (which 403s under the bot rule) but reads still work.
78
101
  await _ensureLumaBridge();
102
+ await _ensureLinkedInBridge();
79
103
  console.log(chalk.gray(" Waiting for pipeline runs...\n"));
80
104
  });
81
105
  socket.on("connect_error", (err) => {
@@ -349,6 +373,14 @@ export async function connect(opts) {
349
373
  MEL_LUMA_BROWSER_TOKEN: lumaBridge.token,
350
374
  }
351
375
  : {}),
376
+ // LinkedIn browser bridge — shared/tools/social_linkedin.py routes
377
+ // feed/read/react-as-page/comment-as-page through Playwright when set.
378
+ ...(linkedinBridge
379
+ ? {
380
+ MEL_LINKEDIN_BROWSER_URL: linkedinBridge.url,
381
+ MEL_LINKEDIN_BROWSER_TOKEN: linkedinBridge.token,
382
+ }
383
+ : {}),
352
384
  // Per-model profile from preflight detection. Empty when
353
385
  // preflight didn't classify (cloud-spawn or capable model with
354
386
  // defaults intact). When set, shared/runtime/model.py reads
@@ -0,0 +1,50 @@
1
+ /**
2
+ * LinkedIn browser bridge — localhost HTTP server that drives a persistent
3
+ * Playwright Chromium context authenticated with the user's LinkedIn session,
4
+ * exposing high-level DOM actions the headless aiohttp path cannot do.
5
+ *
6
+ * Why this exists (vs. the cookie/aiohttp path in social_linkedin.py)
7
+ * ──────────────────────────────────────────────────────────────────
8
+ * LinkedIn's 2024 web rewrite moved feed reading, comment reading/writing, and
9
+ * "react/comment AS A PAGE" onto a Server-Driven-UI (SDUI) surface whose acting
10
+ * identity lives in CLIENT React (`MemoryNamespace`) state set by the on-canvas
11
+ * actor switcher. A replayed cookie request (even from inside the page) can't
12
+ * assert that — verified live 2026-06-17: every cookie-auth reaction recorded as
13
+ * the member, never the page. The only way to act as the page, read the home
14
+ * feed, or read/post comments is to drive the REAL UI in a logged-in browser.
15
+ *
16
+ * Unlike the Luma bridge (a stateless fetch-forwarder), this bridge keeps ONE
17
+ * persistent context + page so:
18
+ * - the home feed scroll position and lazy-loaded cards accumulate, and
19
+ * - a page-actor selection made via the switcher persists across subsequent
20
+ * react/comment actions in the same session.
21
+ *
22
+ * How it works
23
+ * ────────────
24
+ * 1. At runner startup the bridge boots an HTTP server on 127.0.0.1:<free port>,
25
+ * mints a per-session bearer token, and exposes both via env vars
26
+ * `MEL_LINKEDIN_BROWSER_URL` + `MEL_LINKEDIN_BROWSER_TOKEN` to the spawned
27
+ * Python pipeline subprocess. Unlike Luma it boots even with no saved state,
28
+ * because the Python tool injects the session cookies per request (it already
29
+ * holds LINKEDIN_LI_AT / LINKEDIN_JSESSIONID from the credential store).
30
+ * 2. Python POSTs `{action, args, cookies}` to `${URL}/linkedin/action` with
31
+ * `Authorization: Bearer ${TOKEN}`. On the first call the bridge launches
32
+ * Chromium, builds the context, and injects the cookies. Subsequent calls
33
+ * reuse the same page.
34
+ * 3. Actions: `feed` (scroll + extract post cards), `post` (read one post +
35
+ * top comments), `react` (optionally as a page), `comment` (optionally as a
36
+ * page), and `debug_dom` (selector iteration helper).
37
+ *
38
+ * Security
39
+ * ────────
40
+ * - Binds 127.0.0.1 only; random per-session token; cookies never logged.
41
+ * - Only linkedin.com origins are navigated.
42
+ */
43
+ export interface LinkedInBrowserBridge {
44
+ url: string;
45
+ token: string;
46
+ shutdown: () => Promise<void>;
47
+ }
48
+ export declare function startLinkedInBrowserBridge(opts: {
49
+ log: (msg: string) => void;
50
+ }): Promise<LinkedInBrowserBridge | null>;
@@ -0,0 +1,429 @@
1
+ /**
2
+ * LinkedIn browser bridge — localhost HTTP server that drives a persistent
3
+ * Playwright Chromium context authenticated with the user's LinkedIn session,
4
+ * exposing high-level DOM actions the headless aiohttp path cannot do.
5
+ *
6
+ * Why this exists (vs. the cookie/aiohttp path in social_linkedin.py)
7
+ * ──────────────────────────────────────────────────────────────────
8
+ * LinkedIn's 2024 web rewrite moved feed reading, comment reading/writing, and
9
+ * "react/comment AS A PAGE" onto a Server-Driven-UI (SDUI) surface whose acting
10
+ * identity lives in CLIENT React (`MemoryNamespace`) state set by the on-canvas
11
+ * actor switcher. A replayed cookie request (even from inside the page) can't
12
+ * assert that — verified live 2026-06-17: every cookie-auth reaction recorded as
13
+ * the member, never the page. The only way to act as the page, read the home
14
+ * feed, or read/post comments is to drive the REAL UI in a logged-in browser.
15
+ *
16
+ * Unlike the Luma bridge (a stateless fetch-forwarder), this bridge keeps ONE
17
+ * persistent context + page so:
18
+ * - the home feed scroll position and lazy-loaded cards accumulate, and
19
+ * - a page-actor selection made via the switcher persists across subsequent
20
+ * react/comment actions in the same session.
21
+ *
22
+ * How it works
23
+ * ────────────
24
+ * 1. At runner startup the bridge boots an HTTP server on 127.0.0.1:<free port>,
25
+ * mints a per-session bearer token, and exposes both via env vars
26
+ * `MEL_LINKEDIN_BROWSER_URL` + `MEL_LINKEDIN_BROWSER_TOKEN` to the spawned
27
+ * Python pipeline subprocess. Unlike Luma it boots even with no saved state,
28
+ * because the Python tool injects the session cookies per request (it already
29
+ * holds LINKEDIN_LI_AT / LINKEDIN_JSESSIONID from the credential store).
30
+ * 2. Python POSTs `{action, args, cookies}` to `${URL}/linkedin/action` with
31
+ * `Authorization: Bearer ${TOKEN}`. On the first call the bridge launches
32
+ * Chromium, builds the context, and injects the cookies. Subsequent calls
33
+ * reuse the same page.
34
+ * 3. Actions: `feed` (scroll + extract post cards), `post` (read one post +
35
+ * top comments), `react` (optionally as a page), `comment` (optionally as a
36
+ * page), and `debug_dom` (selector iteration helper).
37
+ *
38
+ * Security
39
+ * ────────
40
+ * - Binds 127.0.0.1 only; random per-session token; cookies never logged.
41
+ * - Only linkedin.com origins are navigated.
42
+ */
43
+ import { createServer } from "node:http";
44
+ import { randomBytes } from "node:crypto";
45
+ const MAX_BODY_BYTES = 256 * 1024;
46
+ const NAV_TIMEOUT_MS = 30_000;
47
+ const SETTLE_MS = 1800;
48
+ const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
49
+ "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36";
50
+ export async function startLinkedInBrowserBridge(opts) {
51
+ let playwright;
52
+ try {
53
+ playwright = await import("playwright");
54
+ }
55
+ catch {
56
+ opts.log("LinkedIn browser bridge: playwright module unavailable, skipping.");
57
+ return null;
58
+ }
59
+ const token = randomBytes(24).toString("hex");
60
+ let browser = null;
61
+ let context = null;
62
+ let page = null;
63
+ let cookiesInjected = false;
64
+ // Serialize actions — one shared page can't run two automations at once.
65
+ let chain = Promise.resolve();
66
+ async function _ensurePage(cookies) {
67
+ if (!browser || !browser.isConnected()) {
68
+ browser = await playwright.chromium.launch({
69
+ headless: true,
70
+ args: ["--disable-blink-features=AutomationControlled", "--disable-dev-shm-usage"],
71
+ });
72
+ context = null;
73
+ page = null;
74
+ cookiesInjected = false;
75
+ }
76
+ if (!context) {
77
+ context = await browser.newContext({
78
+ userAgent: UA,
79
+ viewport: { width: 1280, height: 1400 },
80
+ });
81
+ page = null;
82
+ cookiesInjected = false;
83
+ }
84
+ if (!cookiesInjected && cookies && cookies.li_at) {
85
+ const jar = [
86
+ { name: "li_at", value: cookies.li_at, domain: ".linkedin.com", path: "/", httpOnly: true, secure: true },
87
+ ];
88
+ if (cookies.jsessionid) {
89
+ jar.push({
90
+ name: "JSESSIONID",
91
+ value: cookies.jsessionid.replace(/^"|"$/g, ""),
92
+ domain: ".linkedin.com", path: "/", secure: true,
93
+ });
94
+ }
95
+ await context.addCookies(jar);
96
+ cookiesInjected = true;
97
+ }
98
+ if (!page || page.isClosed()) {
99
+ page = await context.newPage();
100
+ }
101
+ return page;
102
+ }
103
+ // ── DOM action implementations ──────────────────────────────────────────
104
+ // Selectors are intentionally resilient (multiple fallbacks + attribute
105
+ // anchors over class names, since LinkedIn rotates hashed classes). The
106
+ // `debug_dom` action exists to re-derive them live when they drift.
107
+ // VALIDATED LIVE 2026-06-17 (Tokano session, my IP — cookie auth works off
108
+ // the user's machine, no checkpoint). KEY FINDING: the home feed is fully
109
+ // SDUI/RSC — every post card uses ROTATING HASHED classes (`_03e8acd7…`) and
110
+ // carries NO `urn:li:activity` DOM attribute. CSS-selector scraping (the impl
111
+ // below) returns 0 posts. The post urns live only in the NETWORK payloads.
112
+ // The proven approach is response interception: attach `context.on("response")`,
113
+ // collect bodies from `flagship-web/rsc-action/...` + `/feed/` (regex
114
+ // `urn:li:activity:\d+` → 4 urns from the initial doc), and trigger the feed
115
+ // pager via scroll to accumulate more. TODO(next session): replace the DOM
116
+ // extraction below with interception + RSC parsing (urn + commentary + author),
117
+ // and capture the `pagers.feed.mainFeed` server-requests for pagination.
118
+ async function _feed(p, args) {
119
+ const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 200);
120
+ await p.goto("https://www.linkedin.com/feed/", { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS });
121
+ await p.waitForTimeout(SETTLE_MS);
122
+ // Scroll until we have >= limit distinct update cards or scrolling stalls.
123
+ const seen = new Set();
124
+ let stalls = 0;
125
+ for (let i = 0; i < 80 && seen.size < limit && stalls < 4; i++) {
126
+ const urns = await p.evaluate(() => Array.from(document.querySelectorAll("[data-urn],[data-id]"))
127
+ .map((e) => e.getAttribute("data-urn") || e.getAttribute("data-id") || "")
128
+ .filter((u) => u.includes("urn:li:activity")));
129
+ const before = seen.size;
130
+ urns.forEach((u) => seen.add(u));
131
+ if (seen.size === before)
132
+ stalls++;
133
+ else
134
+ stalls = 0;
135
+ await p.evaluate(() => window.scrollBy(0, window.innerHeight * 1.5));
136
+ await p.waitForTimeout(900);
137
+ }
138
+ // Extract structured cards.
139
+ const posts = await p.evaluate((max) => {
140
+ const out = [];
141
+ const cards = Array.from(document.querySelectorAll('div.feed-shared-update-v2, [data-urn*="urn:li:activity"]'));
142
+ const pushed = new Set();
143
+ for (const card of cards) {
144
+ const urn = card.getAttribute("data-urn") ||
145
+ card.querySelector("[data-urn]")?.getAttribute("data-urn") ||
146
+ "";
147
+ const activity = (urn.match(/urn:li:activity:\d+/) || [])[0] || "";
148
+ if (!activity || pushed.has(activity))
149
+ continue;
150
+ const actorEl = card.querySelector(".update-components-actor__title, .update-components-actor__name, span.feed-shared-actor__name");
151
+ const author = (actorEl?.innerText || "").split("\n")[0].trim();
152
+ const subEl = card.querySelector(".update-components-actor__description");
153
+ const textEl = card.querySelector(".update-components-text, .feed-shared-update-v2__description, .update-components-update-v2__commentary");
154
+ const text = (textEl?.innerText || "").trim();
155
+ out.push({
156
+ urn: activity,
157
+ author,
158
+ author_subtitle: (subEl?.innerText || "").trim(),
159
+ text: text.slice(0, 2000),
160
+ url: `https://www.linkedin.com/feed/update/${activity}/`,
161
+ });
162
+ pushed.add(activity);
163
+ if (out.length >= max)
164
+ break;
165
+ }
166
+ return out;
167
+ }, limit);
168
+ return { count: posts.length, posts };
169
+ }
170
+ async function _post(p, args) {
171
+ const activity = String(args.urn || args.post || "").match(/urn:li:activity:\d+/)?.[0] || String(args.urn || "");
172
+ const url = activity.startsWith("urn:li:activity")
173
+ ? `https://www.linkedin.com/feed/update/${activity}/`
174
+ : String(args.url || args.urn);
175
+ await p.goto(url, { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS });
176
+ await p.waitForTimeout(SETTLE_MS);
177
+ // Expand "see more" if present.
178
+ try {
179
+ const more = p.locator('button:has-text("see more"), button[aria-label*="see more" i]').first();
180
+ if (await more.count())
181
+ await more.click({ timeout: 2000 }).catch(() => { });
182
+ }
183
+ catch { /* ignore */ }
184
+ const maxComments = Math.min(Math.max(Number(args.max_comments) || 10, 0), 50);
185
+ if (maxComments > 0) {
186
+ try {
187
+ const cbtn = p.locator('button[aria-label*="comment" i]:has-text("Comment"), button:has-text("Comments")').first();
188
+ if (await cbtn.count())
189
+ await cbtn.click({ timeout: 2000 }).catch(() => { });
190
+ await p.waitForTimeout(1200);
191
+ }
192
+ catch { /* ignore */ }
193
+ }
194
+ const data = await p.evaluate((maxC) => {
195
+ const textEl = document.querySelector(".update-components-text, .feed-shared-update-v2__description, .update-components-update-v2__commentary");
196
+ const actorEl = document.querySelector(".update-components-actor__title, .update-components-actor__name");
197
+ const comments = Array.from(document.querySelectorAll("article.comments-comment-entity, .comments-comment-item")).slice(0, maxC).map((c) => {
198
+ const a = c.querySelector(".comments-comment-meta__description-title, .comments-post-meta__name-text");
199
+ const t = c.querySelector(".comments-comment-item__main-content, .update-components-text");
200
+ return { author: (a?.innerText || "").trim(), text: (t?.innerText || "").trim().slice(0, 1000) };
201
+ });
202
+ return {
203
+ author: (actorEl?.innerText || "").split("\n")[0].trim(),
204
+ text: (textEl?.innerText || "").trim(),
205
+ comments,
206
+ };
207
+ }, maxComments);
208
+ return { url, ...data };
209
+ }
210
+ /** Select the org page in the on-canvas "react/comment as" switcher so the
211
+ * subsequent action runs as the page. Returns true if a page actor was set. */
212
+ async function _selectActor(p, asPage) {
213
+ if (!asPage)
214
+ return false;
215
+ // The switcher is a button showing the current actor's avatar/name near the
216
+ // comment box / reaction bar. Clicking opens a menu listing the member and
217
+ // each admined page. We pick the entry whose text matches the page name/urn.
218
+ const needle = String(asPage).toLowerCase();
219
+ const triggers = [
220
+ 'button[aria-label*="Change" i][aria-label*="post as" i]',
221
+ 'button[aria-label*="comment as" i]',
222
+ 'button[aria-label*="react as" i]',
223
+ '.comments-comment-box__detour-container button',
224
+ 'button.update-components-actor__meta-link',
225
+ ];
226
+ for (const sel of triggers) {
227
+ const t = p.locator(sel).first();
228
+ if (await t.count()) {
229
+ await t.click({ timeout: 3000 }).catch(() => { });
230
+ await p.waitForTimeout(800);
231
+ // Menu options
232
+ const opt = p.locator(`[role="menuitem"], [role="option"], li, button`).filter({ hasText: new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") }).first();
233
+ if (await opt.count()) {
234
+ await opt.click({ timeout: 3000 }).catch(() => { });
235
+ await p.waitForTimeout(800);
236
+ return true;
237
+ }
238
+ }
239
+ }
240
+ return false;
241
+ }
242
+ async function _react(p, args) {
243
+ await _post(p, args); // navigate to the post
244
+ const asPage = args.as_page ? String(args.as_page) : "";
245
+ let actedAsPage = false;
246
+ if (asPage)
247
+ actedAsPage = await _selectActor(p, asPage);
248
+ // Open the reaction bar and click the requested reaction (default Like).
249
+ const reaction = String(args.reaction || "Like");
250
+ const likeBtn = p.locator('button[aria-label*="React" i], button:has-text("Like")').first();
251
+ if (!(await likeBtn.count()))
252
+ return { error: "react_button_not_found" };
253
+ if (reaction.toLowerCase() === "like") {
254
+ await likeBtn.click({ timeout: 4000 });
255
+ }
256
+ else {
257
+ await likeBtn.hover();
258
+ await p.waitForTimeout(700);
259
+ const r = p.locator(`button[aria-label*="${reaction}" i]`).first();
260
+ if (await r.count())
261
+ await r.click({ timeout: 3000 });
262
+ else
263
+ await likeBtn.click({ timeout: 4000 });
264
+ }
265
+ await p.waitForTimeout(1000);
266
+ return { reacted: true, reaction, as_page: actedAsPage ? asPage : null };
267
+ }
268
+ async function _comment(p, args) {
269
+ const text = String(args.text || "").trim();
270
+ if (!text)
271
+ return { error: "comment_text_required" };
272
+ await _post(p, args);
273
+ const asPage = args.as_page ? String(args.as_page) : "";
274
+ let actedAsPage = false;
275
+ // Open the comment box first (some layouts require it before the switcher).
276
+ const openCbox = p.locator('button[aria-label*="Comment" i]').first();
277
+ if (await openCbox.count())
278
+ await openCbox.click({ timeout: 3000 }).catch(() => { });
279
+ await p.waitForTimeout(800);
280
+ if (asPage)
281
+ actedAsPage = await _selectActor(p, asPage);
282
+ const editor = p.locator('div.ql-editor[contenteditable="true"], div[role="textbox"][contenteditable="true"]').first();
283
+ if (!(await editor.count()))
284
+ return { error: "comment_editor_not_found" };
285
+ await editor.click({ timeout: 3000 });
286
+ await editor.type(text, { delay: 12 });
287
+ await p.waitForTimeout(500);
288
+ const postBtn = p.locator('button.comments-comment-box__submit-button, button:has-text("Post")').filter({ hasNotText: "Repost" }).first();
289
+ if (!(await postBtn.count()))
290
+ return { error: "comment_post_button_not_found" };
291
+ await postBtn.click({ timeout: 4000 });
292
+ await p.waitForTimeout(1500);
293
+ return { commented: true, as_page: actedAsPage ? asPage : null, text_len: text.length };
294
+ }
295
+ async function _debugDom(p, args) {
296
+ if (args.url) {
297
+ await p.goto(String(args.url), { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS });
298
+ await p.waitForTimeout(SETTLE_MS);
299
+ }
300
+ const sel = String(args.selector || "body");
301
+ return await p.evaluate(({ sel, want }) => {
302
+ const els = Array.from(document.querySelectorAll(sel)).slice(0, Number(want) || 5);
303
+ return els.map((e) => ({
304
+ tag: e.tagName,
305
+ cls: e.className?.toString().slice(0, 200),
306
+ aria: e.getAttribute("aria-label"),
307
+ text: (e.innerText || "").slice(0, 200),
308
+ html: e.outerHTML.slice(0, 400),
309
+ }));
310
+ }, { sel, want: args.limit });
311
+ }
312
+ async function _dispatch(req) {
313
+ try {
314
+ if (req.action === "ping")
315
+ return { ok: true, data: { ready: true } };
316
+ const p = await _ensurePage(req.cookies);
317
+ const args = req.args || {};
318
+ let data;
319
+ switch (req.action) {
320
+ case "feed":
321
+ data = await _feed(p, args);
322
+ break;
323
+ case "post":
324
+ data = await _post(p, args);
325
+ break;
326
+ case "react":
327
+ data = await _react(p, args);
328
+ break;
329
+ case "comment":
330
+ data = await _comment(p, args);
331
+ break;
332
+ case "debug_dom":
333
+ data = await _debugDom(p, args);
334
+ break;
335
+ default: return { ok: false, error: `unknown_action: ${req.action}` };
336
+ }
337
+ return { ok: true, data };
338
+ }
339
+ catch (e) {
340
+ return { ok: false, error: `bridge_error: ${e?.message || e}` };
341
+ }
342
+ }
343
+ // Serialize through the chain so concurrent calls don't fight over the page.
344
+ function dispatch(req) {
345
+ const run = chain.then(() => _dispatch(req));
346
+ chain = run.catch(() => { });
347
+ return run;
348
+ }
349
+ // ── HTTP server ───────────────────────────────────────────────────────────
350
+ const server = createServer(async (req, res) => {
351
+ res.setHeader("Cache-Control", "no-store");
352
+ if (req.method !== "POST") {
353
+ res.statusCode = 405;
354
+ res.end(JSON.stringify({ ok: false, error: "method_not_allowed" }));
355
+ return;
356
+ }
357
+ const auth = String(req.headers["authorization"] || "");
358
+ const expected = `Bearer ${token}`;
359
+ if (auth.length !== expected.length || auth !== expected) {
360
+ res.statusCode = 401;
361
+ res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
362
+ return;
363
+ }
364
+ if (req.url !== "/linkedin/action") {
365
+ res.statusCode = 404;
366
+ res.end(JSON.stringify({ ok: false, error: "not_found" }));
367
+ return;
368
+ }
369
+ const chunks = [];
370
+ let total = 0, oversized = false;
371
+ for await (const chunk of req) {
372
+ total += chunk.length;
373
+ if (total > MAX_BODY_BYTES) {
374
+ oversized = true;
375
+ break;
376
+ }
377
+ chunks.push(chunk);
378
+ }
379
+ if (oversized) {
380
+ res.statusCode = 413;
381
+ res.end(JSON.stringify({ ok: false, error: "payload_too_large" }));
382
+ return;
383
+ }
384
+ let payload;
385
+ try {
386
+ payload = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
387
+ }
388
+ catch {
389
+ res.statusCode = 400;
390
+ res.end(JSON.stringify({ ok: false, error: "invalid_json" }));
391
+ return;
392
+ }
393
+ if (!payload || typeof payload !== "object" || !payload.action) {
394
+ res.statusCode = 400;
395
+ res.end(JSON.stringify({ ok: false, error: "missing_action" }));
396
+ return;
397
+ }
398
+ const out = await dispatch(payload);
399
+ res.statusCode = 200;
400
+ res.setHeader("Content-Type", "application/json");
401
+ res.end(JSON.stringify(out));
402
+ });
403
+ await new Promise((resolve, reject) => {
404
+ server.once("error", reject);
405
+ server.listen(0, "127.0.0.1", () => resolve());
406
+ });
407
+ const addr = server.address();
408
+ if (!addr || typeof addr === "string") {
409
+ server.close();
410
+ return null;
411
+ }
412
+ opts.log(`LinkedIn browser bridge ready on http://127.0.0.1:${addr.port}.`);
413
+ return {
414
+ url: `http://127.0.0.1:${addr.port}`,
415
+ token,
416
+ shutdown: async () => {
417
+ try {
418
+ server.close();
419
+ }
420
+ catch { /* ignore */ }
421
+ if (browser) {
422
+ try {
423
+ await browser.close();
424
+ }
425
+ catch { /* ignore */ }
426
+ }
427
+ },
428
+ };
429
+ }
@@ -149,7 +149,17 @@ export async function runLinkedInLoginFlow(progress) {
149
149
  viewport: { width: 1280, height: 820 },
150
150
  });
151
151
  const page = await context.newPage();
152
- await page.goto("https://www.linkedin.com/login", { waitUntil: "domcontentloaded" });
152
+ // LinkedIn's login page is heavy; "domcontentloaded" routinely exceeds the
153
+ // default 30 s and aborts the whole flow with login_flow_error. "commit"
154
+ // resolves as soon as the navigation commits — the headed window keeps
155
+ // loading and the user signs in normally — and we don't fail the flow on a
156
+ // slow first paint: the poll loop below waits for the logged-in URL anyway.
157
+ try {
158
+ await page.goto("https://www.linkedin.com/login", { waitUntil: "commit", timeout: 60000 });
159
+ }
160
+ catch (e) {
161
+ progress(`Login page is slow to load (${(e?.message || e).toString().split("\n")[0]}); the window is open — sign in there.`);
162
+ }
153
163
  progress("Sign in with your LinkedIn account in the window that just opened.");
154
164
  // 3. Wait for the user to finish signing in. Two terminal conditions:
155
165
  // (a) URL becomes a logged-in LinkedIn path → success.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.65",
3
+ "version": "1.0.66",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,