linkedin-agent 1.0.0
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/README.md +119 -0
- package/assets/default-logo.png +0 -0
- package/dist/auth.js +235 -0
- package/dist/browser.js +72 -0
- package/dist/cli.js +514 -0
- package/dist/dev-app.js +422 -0
- package/dist/index.js +12 -0
- package/dist/poster.js +120 -0
- package/dist/scraper.js +256 -0
- package/package.json +47 -0
- package/scripts/postinstall.js +11 -0
package/dist/dev-app.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* LinkedIn Developer App automation via Playwright
|
|
4
|
+
*
|
|
5
|
+
* Selector Strategy (resilience to UI changes):
|
|
6
|
+
* 1. Semantic class names (.editable-block__update-btn, .editable-list__add-btn)
|
|
7
|
+
* 2. Stable IDs (#createAppNameInput, #clientIdInput)
|
|
8
|
+
* 3. ARIA/role attributes (role="combobox", aria-label)
|
|
9
|
+
* 4. Icon types (li-icon[type="pencil-icon"])
|
|
10
|
+
* 5. Placeholder text as last resort
|
|
11
|
+
*
|
|
12
|
+
* Input Strategy:
|
|
13
|
+
* - Always use keyboard.type() for Ember app compatibility
|
|
14
|
+
* - Use Playwright native click() instead of evaluate-based clicks
|
|
15
|
+
*
|
|
16
|
+
* Never rely on:
|
|
17
|
+
* - Ember dynamic IDs (#ember41, #ember79, etc.)
|
|
18
|
+
* - Text content (language-dependent)
|
|
19
|
+
* - Element index/position without context
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.createDevApp = createDevApp;
|
|
23
|
+
exports.extractCredentials = extractCredentials;
|
|
24
|
+
exports.configureRedirectUri = configureRedirectUri;
|
|
25
|
+
exports.requestProducts = requestProducts;
|
|
26
|
+
exports.verifyScopes = verifyScopes;
|
|
27
|
+
exports.setupDevApp = setupDevApp;
|
|
28
|
+
const browser_1 = require("./browser");
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
/**
|
|
33
|
+
* Try multiple selectors in order, return the first one that matches.
|
|
34
|
+
* This provides resilience when LinkedIn changes one selector but not others.
|
|
35
|
+
*/
|
|
36
|
+
async function findElement(page, selectors, description) {
|
|
37
|
+
for (const selector of selectors) {
|
|
38
|
+
const loc = page.locator(selector).first();
|
|
39
|
+
if ((await loc.count()) > 0 && (await loc.isVisible().catch(() => false))) {
|
|
40
|
+
return loc;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`Could not find: ${description}. Tried: ${selectors.join(", ")}`);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Wait for page to settle (network idle + short delay).
|
|
47
|
+
*/
|
|
48
|
+
async function settle(page, ms = 1500) {
|
|
49
|
+
await page.waitForLoadState("networkidle").catch(() => { });
|
|
50
|
+
await page.waitForTimeout(ms);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Type into an input field using keyboard.type() for Ember compatibility.
|
|
54
|
+
* Clears existing value first.
|
|
55
|
+
*/
|
|
56
|
+
async function typeInto(page, selector, value) {
|
|
57
|
+
const input = page.locator(selector).first();
|
|
58
|
+
await input.click();
|
|
59
|
+
await page.keyboard.press("Meta+a");
|
|
60
|
+
await page.keyboard.press("Backspace");
|
|
61
|
+
await page.keyboard.type(value, { delay: 30 });
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Step 1: Create Developer App
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
const DEFAULT_COMPANY_PAGE = "https://www.linkedin.com/company/103290544/";
|
|
67
|
+
async function createDevApp(page, logoPath, companyPageUrl) {
|
|
68
|
+
console.log("📝 Creating Developer App...");
|
|
69
|
+
await page.goto("https://www.linkedin.com/developers/apps/new", { waitUntil: "domcontentloaded" });
|
|
70
|
+
await settle(page);
|
|
71
|
+
const url = page.url();
|
|
72
|
+
if (!url.includes("/developers/apps")) {
|
|
73
|
+
throw new Error(`Unexpected redirect: ${url}. Login may be required.`);
|
|
74
|
+
}
|
|
75
|
+
// App name — stable semantic ID, fallback to placeholder
|
|
76
|
+
const appName = `social-agent-${Date.now().toString(36)}`;
|
|
77
|
+
const nameInput = await findElement(page, [
|
|
78
|
+
"#createAppNameInput",
|
|
79
|
+
'input[placeholder*="app name" i]',
|
|
80
|
+
], "App name input");
|
|
81
|
+
await nameInput.click();
|
|
82
|
+
await page.keyboard.type(appName, { delay: 30 });
|
|
83
|
+
console.log(` App name: ${appName}`);
|
|
84
|
+
// LinkedIn Page — typeahead accepts company name or page URL
|
|
85
|
+
const companyQuery = companyPageUrl || DEFAULT_COMPANY_PAGE;
|
|
86
|
+
const companyInput = await findElement(page, [
|
|
87
|
+
"#associateCompanyTypeaheadInput",
|
|
88
|
+
'input[role="combobox"]',
|
|
89
|
+
'input[placeholder*="company" i]',
|
|
90
|
+
], "Company typeahead input");
|
|
91
|
+
await companyInput.click();
|
|
92
|
+
await page.keyboard.type(companyQuery, { delay: 20 });
|
|
93
|
+
await page.waitForTimeout(3000);
|
|
94
|
+
// Company may auto-select (shown as a card) or show a dropdown.
|
|
95
|
+
// Check if already selected first (card with dismiss button appears, input disappears).
|
|
96
|
+
const alreadySelected = await page.evaluate(() => {
|
|
97
|
+
// A selected company shows as a card — the typeahead input gets hidden/removed
|
|
98
|
+
const input = document.querySelector("#associateCompanyTypeaheadInput, input[role='combobox']");
|
|
99
|
+
if (!input || !input.offsetParent)
|
|
100
|
+
return true; // input gone = selected
|
|
101
|
+
// Also check for a dismiss/remove button next to company name
|
|
102
|
+
const dismissBtn = document.querySelector("button[aria-label*='dismiss' i], button[aria-label*='remove' i]");
|
|
103
|
+
if (dismissBtn)
|
|
104
|
+
return true;
|
|
105
|
+
return false;
|
|
106
|
+
});
|
|
107
|
+
if (alreadySelected) {
|
|
108
|
+
console.log(` LinkedIn Page: auto-selected from ${companyQuery}`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// Select first typeahead result from dropdown
|
|
112
|
+
const typeaheadOption = await findElement(page, [
|
|
113
|
+
'[role="option"]',
|
|
114
|
+
".basic-typeahead__selectable",
|
|
115
|
+
".search-typeahead-v2__hit",
|
|
116
|
+
'[role="listbox"] li',
|
|
117
|
+
], "Typeahead option");
|
|
118
|
+
await typeaheadOption.click();
|
|
119
|
+
await page.waitForTimeout(500);
|
|
120
|
+
console.log(` LinkedIn Page: ${companyQuery}`);
|
|
121
|
+
}
|
|
122
|
+
// Privacy policy URL — stable semantic ID, fallback to placeholder
|
|
123
|
+
const privacyInput = await findElement(page, [
|
|
124
|
+
"#createAppPrivacyUrlInput",
|
|
125
|
+
'input[placeholder*="http:// or https://" i]',
|
|
126
|
+
], "Privacy policy URL input");
|
|
127
|
+
await privacyInput.click();
|
|
128
|
+
await page.keyboard.type("https://example.com/privacy", { delay: 30 });
|
|
129
|
+
console.log(" Privacy URL: set.");
|
|
130
|
+
// App logo — file input
|
|
131
|
+
const logoInput = await findElement(page, [
|
|
132
|
+
'input[type="file"]',
|
|
133
|
+
"#appLogoImageUpload",
|
|
134
|
+
], "Logo file input");
|
|
135
|
+
await logoInput.setInputFiles(logoPath);
|
|
136
|
+
await page.waitForTimeout(1000);
|
|
137
|
+
console.log(" Logo: uploaded.");
|
|
138
|
+
// Terms checkbox — click the <label> (Ember checkboxes intercept pointer on input)
|
|
139
|
+
const termsLabel = page.locator('label:has-text("agree")').first();
|
|
140
|
+
if ((await termsLabel.count()) > 0) {
|
|
141
|
+
await termsLabel.click();
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
// Fallback: click any checkbox label near "terms"
|
|
145
|
+
await page.evaluate(() => {
|
|
146
|
+
const labels = Array.from(document.querySelectorAll("label"));
|
|
147
|
+
const target = labels.find((l) => l.textContent?.toLowerCase().includes("agree"));
|
|
148
|
+
target?.click();
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
console.log(" Terms: accepted.");
|
|
152
|
+
// Submit — data-control-name is stable, fallback to form submit button
|
|
153
|
+
const createBtn = await findElement(page, [
|
|
154
|
+
'[data-control-name="create_app_form_save_btn"]',
|
|
155
|
+
'button[type="submit"][form="createAppForm"]',
|
|
156
|
+
'button[type="submit"]:has-text("Create")',
|
|
157
|
+
], "Create app button");
|
|
158
|
+
await createBtn.click();
|
|
159
|
+
await settle(page, 5000);
|
|
160
|
+
// Extract app ID from redirect URL
|
|
161
|
+
const afterUrl = page.url();
|
|
162
|
+
const appIdMatch = afterUrl.match(/\/apps\/(\d+)/);
|
|
163
|
+
if (!appIdMatch) {
|
|
164
|
+
throw new Error(`App creation may have failed. URL: ${afterUrl}`);
|
|
165
|
+
}
|
|
166
|
+
console.log(` ✅ App created! ID: ${appIdMatch[1]}\n`);
|
|
167
|
+
return appIdMatch[1];
|
|
168
|
+
}
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Step 2: Extract Credentials
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
async function extractCredentials(page, appId) {
|
|
173
|
+
console.log("🔑 Extracting credentials...");
|
|
174
|
+
await page.goto(`https://www.linkedin.com/developers/apps/${appId}/auth`, { waitUntil: "domcontentloaded" });
|
|
175
|
+
await settle(page);
|
|
176
|
+
// Client ID — stable semantic ID
|
|
177
|
+
const clientId = await page.locator("#clientIdInput").first().inputValue().catch(async () => {
|
|
178
|
+
// Fallback: find input near "Client ID" label
|
|
179
|
+
return page.evaluate(() => {
|
|
180
|
+
const labels = Array.from(document.querySelectorAll("label, span, div"));
|
|
181
|
+
for (const label of labels) {
|
|
182
|
+
if (label.textContent?.trim() === "Client ID:") {
|
|
183
|
+
const input = label.parentElement?.querySelector("input");
|
|
184
|
+
if (input)
|
|
185
|
+
return input.value;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return "";
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
// Client Secret — stable semantic ID, need to reveal first
|
|
192
|
+
let clientSecret = "";
|
|
193
|
+
const secretInput = page.locator("#primaryClientSecret").first();
|
|
194
|
+
if ((await secretInput.count()) > 0) {
|
|
195
|
+
clientSecret = await secretInput.inputValue();
|
|
196
|
+
}
|
|
197
|
+
if (!clientId)
|
|
198
|
+
throw new Error("Could not extract Client ID");
|
|
199
|
+
if (!clientSecret)
|
|
200
|
+
throw new Error("Could not extract Client Secret");
|
|
201
|
+
console.log(` Client ID: ${clientId}`);
|
|
202
|
+
console.log(" Client Secret: ******* (stored)");
|
|
203
|
+
console.log(" ✅ Credentials extracted.\n");
|
|
204
|
+
return { clientId, clientSecret };
|
|
205
|
+
}
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Step 3: Configure Redirect URI
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
async function configureRedirectUri(page, appId, redirectUri) {
|
|
210
|
+
console.log("🔧 Configuring redirect URI...");
|
|
211
|
+
await page.goto(`https://www.linkedin.com/developers/apps/${appId}/auth`, { waitUntil: "domcontentloaded" });
|
|
212
|
+
await settle(page);
|
|
213
|
+
// Check if URI already exists
|
|
214
|
+
const alreadyConfigured = await page.evaluate((uri) => {
|
|
215
|
+
return document.body.textContent?.includes(uri) ?? false;
|
|
216
|
+
}, redirectUri);
|
|
217
|
+
if (alreadyConfigured) {
|
|
218
|
+
console.log(` Already configured: ${redirectUri}`);
|
|
219
|
+
console.log(" ✅ Skipped.\n");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
// Find the editable block containing "redirect" and click its edit button
|
|
223
|
+
// Strategy: find the section by heading text, then find the pencil button inside the same form/block
|
|
224
|
+
const editClicked = await page.evaluate(() => {
|
|
225
|
+
// Find all form/editable-block containers
|
|
226
|
+
const blocks = Array.from(document.querySelectorAll("form"));
|
|
227
|
+
for (const block of blocks) {
|
|
228
|
+
const heading = block.querySelector("h3, h4");
|
|
229
|
+
if (heading?.textContent?.toLowerCase().includes("redirect")) {
|
|
230
|
+
// Find edit button: look for pencil icon button or edit-btn class
|
|
231
|
+
const editBtn = block.querySelector(".editable-block__edit-btn--enabled") ||
|
|
232
|
+
block.querySelector('.editable-block__edit-btn button') ||
|
|
233
|
+
block.querySelector('button:has(li-icon[type="pencil-icon"])') ||
|
|
234
|
+
block.querySelector("button");
|
|
235
|
+
if (editBtn) {
|
|
236
|
+
editBtn.click();
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
});
|
|
243
|
+
if (!editClicked)
|
|
244
|
+
throw new Error("Could not find redirect URI edit button");
|
|
245
|
+
await page.waitForTimeout(1000);
|
|
246
|
+
console.log(" Edit mode entered.");
|
|
247
|
+
// Click "+ Add redirect URL" — semantic class is most stable
|
|
248
|
+
const addBtn = await findElement(page, [
|
|
249
|
+
".editable-list__add-btn",
|
|
250
|
+
'button:has(li-icon[type="plus-icon"])',
|
|
251
|
+
], "Add redirect URL button");
|
|
252
|
+
await addBtn.click();
|
|
253
|
+
await page.waitForTimeout(1000);
|
|
254
|
+
console.log(" Add button clicked.");
|
|
255
|
+
// Fill the input — scope to editable-list container
|
|
256
|
+
const input = await findElement(page, [
|
|
257
|
+
'.editable-list__entry-container input[type="text"]',
|
|
258
|
+
'input[placeholder*="http"]',
|
|
259
|
+
], "Redirect URL input");
|
|
260
|
+
await input.click();
|
|
261
|
+
await page.keyboard.type(redirectUri, { delay: 30 });
|
|
262
|
+
await page.waitForTimeout(500);
|
|
263
|
+
console.log(` Typed: ${redirectUri}`);
|
|
264
|
+
// Click Update — semantic class
|
|
265
|
+
const updateBtn = await findElement(page, [
|
|
266
|
+
".editable-block__update-btn",
|
|
267
|
+
'button[type="submit"]:visible',
|
|
268
|
+
], "Update button");
|
|
269
|
+
await updateBtn.click();
|
|
270
|
+
await settle(page, 3000);
|
|
271
|
+
// Verify save succeeded (no Cancel button means we exited edit mode)
|
|
272
|
+
const stillEditing = (await page.locator(".editable-block__cancel-btn").count()) > 0;
|
|
273
|
+
if (stillEditing) {
|
|
274
|
+
throw new Error("Redirect URI save failed — still in edit mode");
|
|
275
|
+
}
|
|
276
|
+
console.log(" ✅ Redirect URI saved.\n");
|
|
277
|
+
}
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Step 4: Request Products
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
async function requestProducts(page, appId, productNames) {
|
|
282
|
+
console.log("📦 Requesting products...");
|
|
283
|
+
for (const productName of productNames) {
|
|
284
|
+
await page.goto(`https://www.linkedin.com/developers/apps/${appId}/products`, { waitUntil: "domcontentloaded" });
|
|
285
|
+
await settle(page);
|
|
286
|
+
// Check if product is already in "Added products" section
|
|
287
|
+
const alreadyAdded = await page.evaluate((name) => {
|
|
288
|
+
// "Added products" section appears at the top when products are added
|
|
289
|
+
const addedSection = Array.from(document.querySelectorAll("h2, h3")).find((h) => h.textContent?.toLowerCase().includes("added"));
|
|
290
|
+
if (addedSection) {
|
|
291
|
+
const parent = addedSection.closest("div, section");
|
|
292
|
+
if (parent?.textContent?.includes(name))
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}, productName);
|
|
297
|
+
if (alreadyAdded) {
|
|
298
|
+
console.log(` ${productName}: already added.`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
// Find the product card and its "Request access" button
|
|
302
|
+
// Strategy: find h2 with product name, walk up to card, find button
|
|
303
|
+
const clicked = await page.evaluate((name) => {
|
|
304
|
+
const headings = Array.from(document.querySelectorAll("h2, h3, h4"));
|
|
305
|
+
for (const h of headings) {
|
|
306
|
+
if (h.textContent?.trim() === name) {
|
|
307
|
+
// Walk up to find the product card container
|
|
308
|
+
let el = h;
|
|
309
|
+
for (let i = 0; i < 8 && el; i++) {
|
|
310
|
+
el = el.parentElement;
|
|
311
|
+
if (el) {
|
|
312
|
+
const btn = el.querySelector("button");
|
|
313
|
+
if (btn?.textContent?.trim() === "Request access") {
|
|
314
|
+
btn.click();
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return false;
|
|
322
|
+
}, productName);
|
|
323
|
+
if (!clicked) {
|
|
324
|
+
console.log(` ${productName}: not found or no "Request access" button.`);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
await page.waitForTimeout(2000);
|
|
328
|
+
// Handle modal (terms agreement)
|
|
329
|
+
const modal = page.locator('[role="dialog"]').first();
|
|
330
|
+
if ((await modal.count()) > 0 && (await modal.isVisible())) {
|
|
331
|
+
// Accept terms — click label (avoid Ember checkbox interception)
|
|
332
|
+
const label = modal.locator("label").first();
|
|
333
|
+
if ((await label.count()) > 0) {
|
|
334
|
+
await label.click();
|
|
335
|
+
await page.waitForTimeout(300);
|
|
336
|
+
}
|
|
337
|
+
// Confirm — find submit/confirm button inside modal
|
|
338
|
+
const confirmClicked = await page.evaluate(() => {
|
|
339
|
+
const modal = document.querySelector('[role="dialog"]');
|
|
340
|
+
if (!modal)
|
|
341
|
+
return false;
|
|
342
|
+
const btns = Array.from(modal.querySelectorAll("button"));
|
|
343
|
+
const confirmBtn = btns.find((b) => {
|
|
344
|
+
const t = b.textContent?.trim() || "";
|
|
345
|
+
return t.includes("Request access") || t.includes("Submit") || t.includes("Agree");
|
|
346
|
+
});
|
|
347
|
+
if (confirmBtn) {
|
|
348
|
+
confirmBtn.click();
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
return false;
|
|
352
|
+
});
|
|
353
|
+
if (confirmClicked) {
|
|
354
|
+
await settle(page, 3000);
|
|
355
|
+
console.log(` ${productName}: ✅ requested.`);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
console.log(` ${productName}: ⚠️ modal appeared but could not confirm.`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
console.log(` ${productName}: ✅ auto-approved (no modal).`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
console.log("");
|
|
366
|
+
}
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Step 5: Verify Scopes
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
async function verifyScopes(page, appId) {
|
|
371
|
+
console.log("🔍 Verifying OAuth scopes...");
|
|
372
|
+
await page.goto(`https://www.linkedin.com/developers/apps/${appId}/auth`, { waitUntil: "domcontentloaded" });
|
|
373
|
+
await settle(page);
|
|
374
|
+
const scopes = await page.evaluate(() => {
|
|
375
|
+
const results = [];
|
|
376
|
+
// Scopes are bold text (scope name) + description in the scopes section
|
|
377
|
+
const blocks = Array.from(document.querySelectorAll("div, section"));
|
|
378
|
+
for (const block of blocks) {
|
|
379
|
+
const heading = block.querySelector("h2, h3");
|
|
380
|
+
if (heading?.textContent?.toLowerCase().includes("scope")) {
|
|
381
|
+
// Find scope name elements (bold text)
|
|
382
|
+
const scopeEls = Array.from(block.querySelectorAll("b, strong, [class*='bold']"));
|
|
383
|
+
for (const el of scopeEls) {
|
|
384
|
+
const name = el.textContent?.trim() || "";
|
|
385
|
+
if (name && !name.includes("scope") && !name.includes("OAuth") && name.length < 30) {
|
|
386
|
+
results.push(name);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return results;
|
|
393
|
+
});
|
|
394
|
+
if (scopes.length > 0) {
|
|
395
|
+
console.log(" Scopes:");
|
|
396
|
+
for (const s of scopes)
|
|
397
|
+
console.log(` ✅ ${s}`);
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
console.log(" ⚠️ No scopes detected (products may need approval time).");
|
|
401
|
+
}
|
|
402
|
+
console.log("");
|
|
403
|
+
return scopes;
|
|
404
|
+
}
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// Full Flow: Create + Configure
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
const REQUIRED_PRODUCTS = [
|
|
409
|
+
"Share on LinkedIn",
|
|
410
|
+
"Sign In with LinkedIn using OpenID Connect",
|
|
411
|
+
];
|
|
412
|
+
const REDIRECT_URI = "http://localhost:3000/callback";
|
|
413
|
+
async function setupDevApp(context, logoPath, companyPageUrl) {
|
|
414
|
+
const page = context.pages()[0] || (await context.newPage());
|
|
415
|
+
await (0, browser_1.ensureLoggedIn)(page);
|
|
416
|
+
const appId = await createDevApp(page, logoPath, companyPageUrl);
|
|
417
|
+
const creds = await extractCredentials(page, appId);
|
|
418
|
+
await configureRedirectUri(page, appId, REDIRECT_URI);
|
|
419
|
+
await requestProducts(page, appId, REQUIRED_PRODUCTS);
|
|
420
|
+
await verifyScopes(page, appId);
|
|
421
|
+
return { appId, clientId: creds.clientId, clientSecret: creds.clientSecret };
|
|
422
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.authenticate = exports.getValidCredentials = exports.loadCredentials = exports.deleteLinkedInPost = exports.editLinkedInPost = exports.postToLinkedIn = void 0;
|
|
4
|
+
// SDK — agent-friendly functions for programmatic use
|
|
5
|
+
var poster_1 = require("./poster");
|
|
6
|
+
Object.defineProperty(exports, "postToLinkedIn", { enumerable: true, get: function () { return poster_1.postToLinkedIn; } });
|
|
7
|
+
Object.defineProperty(exports, "editLinkedInPost", { enumerable: true, get: function () { return poster_1.editLinkedInPost; } });
|
|
8
|
+
Object.defineProperty(exports, "deleteLinkedInPost", { enumerable: true, get: function () { return poster_1.deleteLinkedInPost; } });
|
|
9
|
+
var auth_1 = require("./auth");
|
|
10
|
+
Object.defineProperty(exports, "loadCredentials", { enumerable: true, get: function () { return auth_1.loadCredentials; } });
|
|
11
|
+
Object.defineProperty(exports, "getValidCredentials", { enumerable: true, get: function () { return auth_1.getValidCredentials; } });
|
|
12
|
+
Object.defineProperty(exports, "authenticate", { enumerable: true, get: function () { return auth_1.authenticate; } });
|
package/dist/poster.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.postToLinkedIn = postToLinkedIn;
|
|
37
|
+
exports.editLinkedInPost = editLinkedInPost;
|
|
38
|
+
exports.deleteLinkedInPost = deleteLinkedInPost;
|
|
39
|
+
const https = __importStar(require("https"));
|
|
40
|
+
const auth_1 = require("./auth");
|
|
41
|
+
function httpsRequest(url, data, headers, method = "POST") {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const urlObj = new URL(url);
|
|
44
|
+
const reqHeaders = { ...headers };
|
|
45
|
+
if (data) {
|
|
46
|
+
reqHeaders["Content-Length"] = Buffer.byteLength(data);
|
|
47
|
+
}
|
|
48
|
+
const req = https.request({
|
|
49
|
+
hostname: urlObj.hostname,
|
|
50
|
+
path: urlObj.pathname + urlObj.search,
|
|
51
|
+
method,
|
|
52
|
+
headers: reqHeaders,
|
|
53
|
+
}, (res) => {
|
|
54
|
+
let body = "";
|
|
55
|
+
res.on("data", (chunk) => (body += chunk));
|
|
56
|
+
res.on("end", () => resolve({ status: res.statusCode || 0, body, headers: res.headers }));
|
|
57
|
+
});
|
|
58
|
+
req.on("error", reject);
|
|
59
|
+
if (data)
|
|
60
|
+
req.write(data);
|
|
61
|
+
req.end();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function makeHeaders(accessToken) {
|
|
65
|
+
return {
|
|
66
|
+
Authorization: `Bearer ${accessToken}`,
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
"X-Restli-Protocol-Version": "2.0.0",
|
|
69
|
+
"LinkedIn-Version": "202601",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async function withRetry(fn, onSuccess) {
|
|
73
|
+
let creds = await (0, auth_1.getValidCredentials)();
|
|
74
|
+
let resp = await fn(creds);
|
|
75
|
+
if (resp.status === 401) {
|
|
76
|
+
console.log("Token expired. Refreshing...");
|
|
77
|
+
creds = await (0, auth_1.refreshAccessToken)(creds);
|
|
78
|
+
resp = await fn(creds);
|
|
79
|
+
}
|
|
80
|
+
if (resp.status >= 200 && resp.status < 300)
|
|
81
|
+
return onSuccess(resp);
|
|
82
|
+
return { success: false, error: `HTTP ${resp.status}: ${resp.body}` };
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Create
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
async function postToLinkedIn(options) {
|
|
88
|
+
return withRetry((creds) => {
|
|
89
|
+
const body = {
|
|
90
|
+
author: `urn:li:person:${creds.personId}`,
|
|
91
|
+
commentary: options.text,
|
|
92
|
+
visibility: "PUBLIC",
|
|
93
|
+
distribution: {
|
|
94
|
+
feedDistribution: "MAIN_FEED",
|
|
95
|
+
targetEntities: [],
|
|
96
|
+
thirdPartyDistributionChannels: [],
|
|
97
|
+
},
|
|
98
|
+
lifecycleState: "PUBLISHED",
|
|
99
|
+
isReshareDisabledByAuthor: false,
|
|
100
|
+
};
|
|
101
|
+
if (options.linkUrl) {
|
|
102
|
+
body.content = { article: { source: options.linkUrl } };
|
|
103
|
+
}
|
|
104
|
+
return httpsRequest("https://api.linkedin.com/rest/posts", JSON.stringify(body), makeHeaders(creds.accessToken));
|
|
105
|
+
}, (resp) => ({ success: true, postId: resp.headers["x-restli-id"] || "" }));
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Edit
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
async function editLinkedInPost(options) {
|
|
111
|
+
const encodedId = encodeURIComponent(options.postId);
|
|
112
|
+
return withRetry((creds) => httpsRequest(`https://api.linkedin.com/rest/posts/${encodedId}`, JSON.stringify({ patch: { $set: { commentary: options.text } } }), makeHeaders(creds.accessToken), "POST"), () => ({ success: true, postId: options.postId }));
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Delete
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
async function deleteLinkedInPost(postId) {
|
|
118
|
+
const encodedId = encodeURIComponent(postId);
|
|
119
|
+
return withRetry((creds) => httpsRequest(`https://api.linkedin.com/rest/posts/${encodedId}`, "", makeHeaders(creds.accessToken), "DELETE"), () => ({ success: true, postId }));
|
|
120
|
+
}
|