@melaya/runner 1.0.66 → 1.0.68

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,7 +25,6 @@ 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";
29
28
  const HEARTBEAT_INTERVAL = 30_000;
30
29
  const activeProcesses = new Map();
31
30
  export async function connect(opts) {
@@ -62,28 +61,6 @@ export async function connect(opts) {
62
61
  console.log(chalk.yellow(` ⚠ Luma browser bridge failed to start: ${e?.message || e}`));
63
62
  }
64
63
  };
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
- };
87
64
  socket.on("connect", async () => {
88
65
  spinner.succeed(chalk.green("Connected to Melaya"));
89
66
  // Report detected models
@@ -99,7 +76,6 @@ export async function connect(opts) {
99
76
  // effort — failure here means Luma POSTs fall back to aiohttp
100
77
  // (which 403s under the bot rule) but reads still work.
101
78
  await _ensureLumaBridge();
102
- await _ensureLinkedInBridge();
103
79
  console.log(chalk.gray(" Waiting for pipeline runs...\n"));
104
80
  });
105
81
  socket.on("connect_error", (err) => {
@@ -373,14 +349,6 @@ export async function connect(opts) {
373
349
  MEL_LUMA_BROWSER_TOKEN: lumaBridge.token,
374
350
  }
375
351
  : {}),
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
- : {}),
384
352
  // Per-model profile from preflight detection. Empty when
385
353
  // preflight didn't classify (cloud-spawn or capable model with
386
354
  // defaults intact). When set, shared/runtime/model.py reads
@@ -1194,6 +1162,16 @@ export async function connect(opts) {
1194
1162
  }
1195
1163
  catch { /* best-effort */ }
1196
1164
  });
1165
+ // Server relays this when the user cancels a stuck LinkedIn sign-in to retry.
1166
+ socket.on("linkedin:cancel-login", async (_req) => {
1167
+ try {
1168
+ const { cancelActiveLinkedInLogin } = await import("./linkedinLogin.js");
1169
+ const closed = await cancelActiveLinkedInLogin();
1170
+ if (closed)
1171
+ console.log(chalk.gray(" [linkedin] sign-in window closed at server's request (cancel & retry)."));
1172
+ }
1173
+ catch { /* best-effort */ }
1174
+ });
1197
1175
  // ── Kill command ───────────────────────────────────────────────────
1198
1176
  socket.on("runner:kill", (data) => {
1199
1177
  const proc = activeProcesses.get(data.runId);
@@ -55,6 +55,9 @@ export type LinkedInLoginOutcome = LinkedInLoginResult | LinkedInLoginError;
55
55
  interface ProgressEmitter {
56
56
  (msg: string): void;
57
57
  }
58
+ /** Close the in-flight LinkedIn sign-in browser, if any. Returns true if a
59
+ * window was actually open. Best-effort — never throws. */
60
+ export declare function cancelActiveLinkedInLogin(): Promise<boolean>;
58
61
  export declare function runLinkedInLoginFlow(progress: ProgressEmitter): Promise<LinkedInLoginOutcome>;
59
62
  /**
60
63
  * Pretty-print a one-liner summary of the outcome — used by the runner
@@ -116,6 +116,24 @@ function _pageLooksLoggedIn(url) {
116
116
  return false;
117
117
  }
118
118
  }
119
+ // Handle to the in-flight sign-in browser so the server's `linkedin:cancel-login`
120
+ // (relayed via connection.ts) can close a window the user abandoned. Only one
121
+ // login runs per runner at a time, so a single module-level handle suffices.
122
+ // Closing it makes the poll loop's page.url() throw → flow returns user_cancelled.
123
+ let _activeLoginBrowser = null;
124
+ /** Close the in-flight LinkedIn sign-in browser, if any. Returns true if a
125
+ * window was actually open. Best-effort — never throws. */
126
+ export async function cancelActiveLinkedInLogin() {
127
+ const b = _activeLoginBrowser;
128
+ if (!b)
129
+ return false;
130
+ _activeLoginBrowser = null;
131
+ try {
132
+ await b.close();
133
+ }
134
+ catch { /* already closed */ }
135
+ return true;
136
+ }
119
137
  export async function runLinkedInLoginFlow(progress) {
120
138
  // 1. Ensure Playwright + Chromium are available.
121
139
  let playwright;
@@ -143,23 +161,14 @@ export async function runLinkedInLoginFlow(progress) {
143
161
  "--disable-blink-features=AutomationControlled",
144
162
  ],
145
163
  });
164
+ _activeLoginBrowser = browser;
146
165
  const context = await browser.newContext({
147
166
  userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
148
167
  "(KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
149
168
  viewport: { width: 1280, height: 820 },
150
169
  });
151
170
  const page = await context.newPage();
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
- }
171
+ await page.goto("https://www.linkedin.com/login", { waitUntil: "domcontentloaded" });
163
172
  progress("Sign in with your LinkedIn account in the window that just opened.");
164
173
  // 3. Wait for the user to finish signing in. Two terminal conditions:
165
174
  // (a) URL becomes a logged-in LinkedIn path → success.
@@ -238,6 +247,7 @@ export async function runLinkedInLoginFlow(progress) {
238
247
  }
239
248
  catch { /* already closed */ }
240
249
  }
250
+ _activeLoginBrowser = null;
241
251
  }
242
252
  }
243
253
  /**
package/package.json CHANGED
@@ -1,41 +1,41 @@
1
- {
2
- "name": "@melaya/runner",
3
- "version": "1.0.66",
4
- "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
- "license": "UNLICENSED",
6
- "private": false,
7
- "type": "module",
8
- "bin": {
9
- "melaya-runner": "dist/cli.js"
10
- },
11
- "main": "dist/index.js",
12
- "files": [
13
- "dist/**/*.js",
14
- "dist/**/*.d.ts",
15
- "dist/**/*.py",
16
- "localRagIngest.py",
17
- "nltk_data/**",
18
- "README.md"
19
- ],
20
- "scripts": {
21
- "build": "tsc && node -e \"require('fs').copyFileSync('localRagIngest.py','dist/localRagIngest.py')\"",
22
- "prepublishOnly": "npm run build"
23
- },
24
- "dependencies": {
25
- "socket.io-client": "^4.8.0",
26
- "commander": "^12.0.0",
27
- "chalk": "^5.3.0",
28
- "ora": "^8.0.0",
29
- "playwright": "^1.47.0"
30
- },
31
- "devDependencies": {
32
- "typescript": "^5.5.0",
33
- "@types/node": "^20.0.0"
34
- },
35
- "engines": {
36
- "node": ">=18"
37
- },
38
- "publishConfig": {
39
- "access": "public"
40
- }
41
- }
1
+ {
2
+ "name": "@melaya/runner",
3
+ "version": "1.0.68",
4
+ "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
+ "license": "UNLICENSED",
6
+ "private": false,
7
+ "type": "module",
8
+ "bin": {
9
+ "melaya-runner": "dist/cli.js"
10
+ },
11
+ "main": "dist/index.js",
12
+ "files": [
13
+ "dist/**/*.js",
14
+ "dist/**/*.d.ts",
15
+ "dist/**/*.py",
16
+ "localRagIngest.py",
17
+ "nltk_data/**",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc && node -e \"require('fs').copyFileSync('localRagIngest.py','dist/localRagIngest.py')\"",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "dependencies": {
25
+ "socket.io-client": "^4.8.0",
26
+ "commander": "^12.0.0",
27
+ "chalk": "^5.3.0",
28
+ "ora": "^8.0.0",
29
+ "playwright": "^1.47.0"
30
+ },
31
+ "devDependencies": {
32
+ "typescript": "^5.5.0",
33
+ "@types/node": "^20.0.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }
@@ -1,50 +0,0 @@
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>;
@@ -1,429 +0,0 @@
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
- }