@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.
- package/dist/connection.js +32 -0
- package/dist/linkedinBrowserBridge.d.ts +50 -0
- package/dist/linkedinBrowserBridge.js +429 -0
- package/dist/linkedinLogin.js +11 -1
- package/package.json +1 -1
package/dist/connection.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/linkedinLogin.js
CHANGED
|
@@ -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
|
-
|
|
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.
|