linkedin-automation-cli 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/.env.example +12 -0
- package/.github/workflows/ci.yml +66 -0
- package/.github/workflows/publish.yml +48 -0
- package/.husky/pre-commit +6 -0
- package/.prettierignore +4 -0
- package/.prettierrc +10 -0
- package/AGENTS.md +294 -0
- package/CHANGELOG.md +40 -0
- package/GIT_RELEASE.md +167 -0
- package/LICENSE +21 -0
- package/Makefile +30 -0
- package/NPM_PUBLISHING.md +230 -0
- package/PYEOF +0 -0
- package/README.md +295 -0
- package/TESTING-GUIDE.md +151 -0
- package/cmd/linkedin/main.go +9 -0
- package/dist/agent/action-executor.d.ts +81 -0
- package/dist/agent/action-executor.d.ts.map +1 -0
- package/dist/agent/action-executor.js +170 -0
- package/dist/agent/action-executor.js.map +1 -0
- package/dist/agent/action-executor.test.d.ts +2 -0
- package/dist/agent/action-executor.test.d.ts.map +1 -0
- package/dist/agent/action-executor.test.js +366 -0
- package/dist/agent/action-executor.test.js.map +1 -0
- package/dist/agent/claude-client.d.ts +74 -0
- package/dist/agent/claude-client.d.ts.map +1 -0
- package/dist/agent/claude-client.js +314 -0
- package/dist/agent/claude-client.js.map +1 -0
- package/dist/agent/claude-client.test.d.ts +2 -0
- package/dist/agent/claude-client.test.d.ts.map +1 -0
- package/dist/agent/claude-client.test.js +590 -0
- package/dist/agent/claude-client.test.js.map +1 -0
- package/dist/agent/dom-extractor.d.ts +50 -0
- package/dist/agent/dom-extractor.d.ts.map +1 -0
- package/dist/agent/dom-extractor.js +374 -0
- package/dist/agent/dom-extractor.js.map +1 -0
- package/dist/agent/dom-extractor.test.d.ts +7 -0
- package/dist/agent/dom-extractor.test.d.ts.map +1 -0
- package/dist/agent/dom-extractor.test.js +504 -0
- package/dist/agent/dom-extractor.test.js.map +1 -0
- package/dist/agent/extension-client.d.ts +75 -0
- package/dist/agent/extension-client.d.ts.map +1 -0
- package/dist/agent/extension-client.js +245 -0
- package/dist/agent/extension-client.js.map +1 -0
- package/dist/agent/index.d.ts +8 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +16 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/agent/page-agent.d.ts +76 -0
- package/dist/agent/page-agent.d.ts.map +1 -0
- package/dist/agent/page-agent.js +236 -0
- package/dist/agent/page-agent.js.map +1 -0
- package/dist/agent/types.d.ts +236 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/types.js +37 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/cli/agent-commands.d.ts +3 -0
- package/dist/cli/agent-commands.d.ts.map +1 -0
- package/dist/cli/agent-commands.js +250 -0
- package/dist/cli/agent-commands.js.map +1 -0
- package/dist/cli/auth.d.ts +3 -0
- package/dist/cli/auth.d.ts.map +1 -0
- package/dist/cli/auth.js +288 -0
- package/dist/cli/auth.js.map +1 -0
- package/dist/cli/company.d.ts +3 -0
- package/dist/cli/company.d.ts.map +1 -0
- package/dist/cli/company.js +55 -0
- package/dist/cli/company.js.map +1 -0
- package/dist/cli/connection.d.ts +3 -0
- package/dist/cli/connection.d.ts.map +1 -0
- package/dist/cli/connection.js +79 -0
- package/dist/cli/connection.js.map +1 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +17 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/messages.d.ts +3 -0
- package/dist/cli/messages.d.ts.map +1 -0
- package/dist/cli/messages.js +268 -0
- package/dist/cli/messages.js.map +1 -0
- package/dist/cli/profile.d.ts +3 -0
- package/dist/cli/profile.d.ts.map +1 -0
- package/dist/cli/profile.js +81 -0
- package/dist/cli/profile.js.map +1 -0
- package/dist/cli/profile.test.d.ts +2 -0
- package/dist/cli/profile.test.d.ts.map +1 -0
- package/dist/cli/profile.test.js +15 -0
- package/dist/cli/profile.test.js.map +1 -0
- package/dist/cli/reply.d.ts +3 -0
- package/dist/cli/reply.d.ts.map +1 -0
- package/dist/cli/reply.js +129 -0
- package/dist/cli/reply.js.map +1 -0
- package/dist/core/audit.d.ts +17 -0
- package/dist/core/audit.d.ts.map +1 -0
- package/dist/core/audit.js +121 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/audit.test.d.ts +2 -0
- package/dist/core/audit.test.d.ts.map +1 -0
- package/dist/core/audit.test.js +142 -0
- package/dist/core/audit.test.js.map +1 -0
- package/dist/core/browser-cookies.d.ts +19 -0
- package/dist/core/browser-cookies.d.ts.map +1 -0
- package/dist/core/browser-cookies.js +181 -0
- package/dist/core/browser-cookies.js.map +1 -0
- package/dist/core/browser.d.ts +50 -0
- package/dist/core/browser.d.ts.map +1 -0
- package/dist/core/browser.js +318 -0
- package/dist/core/browser.js.map +1 -0
- package/dist/core/config.d.ts +20 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +103 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/config.test.d.ts +2 -0
- package/dist/core/config.test.d.ts.map +1 -0
- package/dist/core/config.test.js +111 -0
- package/dist/core/config.test.js.map +1 -0
- package/dist/core/storage.d.ts +19 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/storage.js +124 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/core/storage.test.d.ts +2 -0
- package/dist/core/storage.test.d.ts.map +1 -0
- package/dist/core/storage.test.js +142 -0
- package/dist/core/storage.test.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/linkedin/auth.d.ts +22 -0
- package/dist/linkedin/auth.d.ts.map +1 -0
- package/dist/linkedin/auth.js +167 -0
- package/dist/linkedin/auth.js.map +1 -0
- package/dist/linkedin/company-extractor.d.ts +36 -0
- package/dist/linkedin/company-extractor.d.ts.map +1 -0
- package/dist/linkedin/company-extractor.js +211 -0
- package/dist/linkedin/company-extractor.js.map +1 -0
- package/dist/linkedin/company-extractor.test.d.ts +2 -0
- package/dist/linkedin/company-extractor.test.d.ts.map +1 -0
- package/dist/linkedin/company-extractor.test.js +52 -0
- package/dist/linkedin/company-extractor.test.js.map +1 -0
- package/dist/linkedin/connector.d.ts +45 -0
- package/dist/linkedin/connector.d.ts.map +1 -0
- package/dist/linkedin/connector.js +245 -0
- package/dist/linkedin/connector.js.map +1 -0
- package/dist/linkedin/message-sender.d.ts +32 -0
- package/dist/linkedin/message-sender.d.ts.map +1 -0
- package/dist/linkedin/message-sender.js +112 -0
- package/dist/linkedin/message-sender.js.map +1 -0
- package/dist/linkedin/messages.d.ts +78 -0
- package/dist/linkedin/messages.d.ts.map +1 -0
- package/dist/linkedin/messages.js +745 -0
- package/dist/linkedin/messages.js.map +1 -0
- package/dist/linkedin/profile.d.ts +37 -0
- package/dist/linkedin/profile.d.ts.map +1 -0
- package/dist/linkedin/profile.js +268 -0
- package/dist/linkedin/profile.js.map +1 -0
- package/dist/linkedin/profile.test.d.ts +2 -0
- package/dist/linkedin/profile.test.d.ts.map +1 -0
- package/dist/linkedin/profile.test.js +68 -0
- package/dist/linkedin/profile.test.js.map +1 -0
- package/dist/linkedin/reply.d.ts +21 -0
- package/dist/linkedin/reply.d.ts.map +1 -0
- package/dist/linkedin/reply.js +76 -0
- package/dist/linkedin/reply.js.map +1 -0
- package/dist/linkedin/selector-engine.d.ts +69 -0
- package/dist/linkedin/selector-engine.d.ts.map +1 -0
- package/dist/linkedin/selector-engine.js +339 -0
- package/dist/linkedin/selector-engine.js.map +1 -0
- package/dist/linkedin/selector-engine.test.d.ts +2 -0
- package/dist/linkedin/selector-engine.test.d.ts.map +1 -0
- package/dist/linkedin/selector-engine.test.js +135 -0
- package/dist/linkedin/selector-engine.test.js.map +1 -0
- package/dist/linkedin/selectors.d.ts +65 -0
- package/dist/linkedin/selectors.d.ts.map +1 -0
- package/dist/linkedin/selectors.js +261 -0
- package/dist/linkedin/selectors.js.map +1 -0
- package/dist/templates/engine.d.ts +37 -0
- package/dist/templates/engine.d.ts.map +1 -0
- package/dist/templates/engine.js +215 -0
- package/dist/templates/engine.js.map +1 -0
- package/dist/templates/engine.test.d.ts +2 -0
- package/dist/templates/engine.test.d.ts.map +1 -0
- package/dist/templates/engine.test.js +212 -0
- package/dist/templates/engine.test.js.map +1 -0
- package/dist/templates/index.d.ts +2 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +7 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/types/index.d.ts +113 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/index.test.d.ts +2 -0
- package/dist/types/index.test.d.ts.map +1 -0
- package/dist/types/index.test.js +90 -0
- package/dist/types/index.test.js.map +1 -0
- package/dist/utils/paths.d.ts +8 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +68 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +22 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +57 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- package/dist/utils/retry.d.ts +18 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/retry.js +49 -0
- package/dist/utils/retry.js.map +1 -0
- package/docs/connection-command.md +52 -0
- package/docs/plans/2025-03-03-linkedin-cli-design.md +280 -0
- package/docs/plans/2025-03-03-linkedin-cli-implementation-plan.md +2087 -0
- package/docs/plans/2025-03-03-linkedin-cli-implementation.md +2420 -0
- package/docs/plans/2026-02-19-linkedin-connection-feature.md +596 -0
- package/docs/plans/2026-02-28-messages-send-feature.md +480 -0
- package/docs/plans/2026-02-28-messages-show-design.md +243 -0
- package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-design.md +394 -0
- package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-plan.md +1592 -0
- package/docs/superpowers/plans/2026-03-13-linkedin-automation-resilience-migration.md +425 -0
- package/docs/superpowers/plans/2026-03-13-playwright-fara-migration.md +1112 -0
- package/docs/superpowers/plans/2026-03-14-page-agent-plan.md +1598 -0
- package/docs/superpowers/plans/2026-03-15-company-profile-extraction.md +591 -0
- package/docs/superpowers/plans/2026-03-15-profile-extraction-plan.md +943 -0
- package/docs/superpowers/specs/2026-03-14-company-profile-extraction-design.md +371 -0
- package/docs/superpowers/specs/2026-03-14-page-agent-design.md +385 -0
- package/docs/superpowers/specs/2026-03-15-profile-extraction-design.md +409 -0
- package/eslint.config.mjs +58 -0
- package/go.mod +9 -0
- package/go.sum +10 -0
- package/import-cookies.js +376 -0
- package/internal/cmd/actions.go +123 -0
- package/internal/cmd/auth.go +108 -0
- package/internal/cmd/connect.go +42 -0
- package/internal/cmd/message.go +44 -0
- package/internal/cmd/people.go +454 -0
- package/internal/cmd/profiles.go +121 -0
- package/internal/cmd/root.go +89 -0
- package/internal/cmd/sequence.go +192 -0
- package/internal/config/config.go +187 -0
- package/internal/config/config_test.go +121 -0
- package/internal/config/profile.go +65 -0
- package/internal/linkedin/navigator.go +195 -0
- package/internal/linkedin/selectors.go +39 -0
- package/internal/linkedin/validator.go +69 -0
- package/internal/pinchtab/client.go +183 -0
- package/internal/pinchtab/client_test.go +67 -0
- package/internal/pinchtab/types.go +50 -0
- package/internal/ratelimit/limiter.go +115 -0
- package/internal/ratelimit/limits.go +32 -0
- package/package.json +67 -0
- package/release.sh +66 -0
- package/scripts/debug-linkedin.js +156 -0
- package/scripts/debug-login.js +193 -0
- package/scripts/extract-from-edge.js +96 -0
- package/scripts/import-cookies.js +101 -0
- package/scripts/poc-show-data.js +205 -0
- package/scripts/proof-of-access.js +87 -0
- package/scripts/prove-connection.js +110 -0
- package/scripts/show-linkedin-data.js +173 -0
- package/src/agent/action-executor.test.ts +464 -0
- package/src/agent/action-executor.ts +203 -0
- package/src/agent/claude-client.test.ts +707 -0
- package/src/agent/claude-client.ts +422 -0
- package/src/agent/dom-extractor.test.ts +574 -0
- package/src/agent/dom-extractor.ts +437 -0
- package/src/agent/extension-client.ts +306 -0
- package/src/agent/index.ts +28 -0
- package/src/agent/page-agent.ts +292 -0
- package/src/agent/types.ts +288 -0
- package/src/cli/agent-commands.ts +274 -0
- package/src/cli/auth.ts +343 -0
- package/src/cli/company.ts +66 -0
- package/src/cli/connection.ts +89 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/messages.ts +338 -0
- package/src/cli/profile.test.ts +14 -0
- package/src/cli/profile.ts +95 -0
- package/src/cli/reply.ts +110 -0
- package/src/core/audit.test.ts +134 -0
- package/src/core/audit.ts +98 -0
- package/src/core/browser-cookies.ts +203 -0
- package/src/core/browser.ts +304 -0
- package/src/core/config.test.ts +90 -0
- package/src/core/config.ts +81 -0
- package/src/core/storage.test.ts +129 -0
- package/src/core/storage.ts +100 -0
- package/src/index.ts +70 -0
- package/src/linkedin/auth.ts +218 -0
- package/src/linkedin/company-extractor.test.ts +58 -0
- package/src/linkedin/company-extractor.ts +222 -0
- package/src/linkedin/connector.ts +336 -0
- package/src/linkedin/message-sender.ts +141 -0
- package/src/linkedin/messages.ts +894 -0
- package/src/linkedin/profile.test.ts +79 -0
- package/src/linkedin/profile.ts +314 -0
- package/src/linkedin/reply.ts +96 -0
- package/src/linkedin/selector-engine.test.ts +167 -0
- package/src/linkedin/selector-engine.ts +393 -0
- package/src/linkedin/selectors.ts +268 -0
- package/src/templates/defaults/followup.txt +14 -0
- package/src/templates/defaults/meeting.txt +16 -0
- package/src/templates/defaults/welcome.txt +14 -0
- package/src/templates/engine.test.ts +228 -0
- package/src/templates/engine.ts +208 -0
- package/src/templates/index.ts +1 -0
- package/src/types/index.test.ts +94 -0
- package/src/types/index.ts +143 -0
- package/src/types/sql.js.d.ts +23 -0
- package/src/utils/paths.ts +33 -0
- package/src/utils/rate-limiter.ts +75 -0
- package/src/utils/retry.ts +78 -0
- package/test-cli.sh +85 -0
- package/test-real-data.sh +97 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +35 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Company Profile Extractor
|
|
3
|
+
*
|
|
4
|
+
* Extracts structured data from LinkedIn company profile pages.
|
|
5
|
+
* No authentication required - company pages are public.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Page } from 'playwright';
|
|
9
|
+
import type { CompanyProfile } from '../types';
|
|
10
|
+
import { SELECTORS } from './selectors';
|
|
11
|
+
|
|
12
|
+
/** URL pattern for valid LinkedIn company pages */
|
|
13
|
+
const COMPANY_URL_PATTERN = /^https:\/\/www\.linkedin\.com\/company\/[^\/]+\/?$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validate if a URL is a valid LinkedIn company URL
|
|
17
|
+
*/
|
|
18
|
+
export function isValidCompanyUrl(url: string): boolean {
|
|
19
|
+
return COMPANY_URL_PATTERN.test(url);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse follower count string to number
|
|
24
|
+
* Handles formats like "2.5M followers", "12K", "1,234"
|
|
25
|
+
*/
|
|
26
|
+
export function parseFollowerCount(text: string | null): number | null {
|
|
27
|
+
if (!text) return null;
|
|
28
|
+
|
|
29
|
+
// Extract numeric part with optional K/M suffix
|
|
30
|
+
const match = text.match(/([\d,.]+)\s*([KMkm]?)/);
|
|
31
|
+
if (!match) return null;
|
|
32
|
+
|
|
33
|
+
let num = parseFloat(match[1].replace(/,/g, ''));
|
|
34
|
+
const suffix = match[2].toUpperCase();
|
|
35
|
+
|
|
36
|
+
if (suffix === 'K') num *= 1000;
|
|
37
|
+
else if (suffix === 'M') num *= 1000000;
|
|
38
|
+
|
|
39
|
+
return Math.round(num);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse specialties string to array
|
|
44
|
+
* Splits by comma and trims whitespace
|
|
45
|
+
*/
|
|
46
|
+
export function parseSpecialties(text: string | null): string[] | null {
|
|
47
|
+
if (!text) return null;
|
|
48
|
+
|
|
49
|
+
const specialties = text
|
|
50
|
+
.split(',')
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.filter((s) => s.length > 0);
|
|
53
|
+
|
|
54
|
+
return specialties.length > 0 ? specialties : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Extract definition list data by matching dt text to dd value
|
|
59
|
+
*/
|
|
60
|
+
async function extractDefinitionList(page: Page): Promise<Map<string, string>> {
|
|
61
|
+
const result = new Map<string, string>();
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const dts = await page.locator('dt').all();
|
|
65
|
+
const dds = await page.locator('dd').all();
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < Math.min(dts.length, dds.length); i++) {
|
|
68
|
+
const dtText = (await dts[i].textContent())?.trim() || '';
|
|
69
|
+
const ddText = (await dds[i].textContent())?.trim() || '';
|
|
70
|
+
if (dtText && ddText) {
|
|
71
|
+
result.set(dtText.toLowerCase(), ddText);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Ignore errors
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract href from a link element using selector fallbacks
|
|
83
|
+
*/
|
|
84
|
+
async function extractHref(page: Page, selectors: readonly string[]): Promise<string | null> {
|
|
85
|
+
for (const selector of selectors) {
|
|
86
|
+
try {
|
|
87
|
+
const element = page.locator(selector).first();
|
|
88
|
+
const href = await element.getAttribute('href', { timeout: 2000 });
|
|
89
|
+
if (href?.trim()) {
|
|
90
|
+
return href.trim();
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Try next selector
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Dismiss authwall popup if present
|
|
101
|
+
*/
|
|
102
|
+
async function dismissAuthwall(page: Page): Promise<void> {
|
|
103
|
+
const selectors = SELECTORS.company.dismissAuthwall;
|
|
104
|
+
|
|
105
|
+
for (const selector of selectors) {
|
|
106
|
+
try {
|
|
107
|
+
const btn = page.locator(selector).first();
|
|
108
|
+
if (await btn.isVisible({ timeout: 1000 })) {
|
|
109
|
+
await btn.click();
|
|
110
|
+
await page.waitForTimeout(500);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Try next selector
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Company Profile Extractor
|
|
121
|
+
*
|
|
122
|
+
* Extracts structured data from LinkedIn company profile pages.
|
|
123
|
+
*/
|
|
124
|
+
export class CompanyExtractor {
|
|
125
|
+
constructor(private page: Page) {}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract company profile data from a LinkedIn company URL
|
|
129
|
+
*/
|
|
130
|
+
async extract(url: string): Promise<CompanyProfile> {
|
|
131
|
+
// Navigate to company page with forced reload
|
|
132
|
+
await this.page.goto(url, {
|
|
133
|
+
waitUntil: 'domcontentloaded',
|
|
134
|
+
timeout: 30000,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Force reload to ensure fresh content (helps with CDP connections)
|
|
138
|
+
await this.page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
139
|
+
|
|
140
|
+
// Wait for page to load
|
|
141
|
+
await this.page.waitForTimeout(5000);
|
|
142
|
+
|
|
143
|
+
// Dismiss authwall popup if present
|
|
144
|
+
await dismissAuthwall(this.page);
|
|
145
|
+
|
|
146
|
+
// Wait additional time for dynamic content
|
|
147
|
+
await this.page.waitForTimeout(2000);
|
|
148
|
+
|
|
149
|
+
// Wait for about section to load (dt/dd elements)
|
|
150
|
+
try {
|
|
151
|
+
await this.page.waitForSelector('dt', { timeout: 10000 });
|
|
152
|
+
} catch {
|
|
153
|
+
// Continue anyway
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Extract definition list data (dt/dd pairs)
|
|
157
|
+
const definitions = await extractDefinitionList(this.page);
|
|
158
|
+
|
|
159
|
+
// Extract name from top card (clean up whitespace)
|
|
160
|
+
const rawName = await this.page.locator('h1').first().textContent({ timeout: 5000 });
|
|
161
|
+
const name = rawName?.trim().replace(/\s+/g, ' ') || '';
|
|
162
|
+
|
|
163
|
+
// Extract website from definition list (first link in dd)
|
|
164
|
+
let website: string | null = null;
|
|
165
|
+
try {
|
|
166
|
+
const websiteLink = this.page.locator('dt:has-text("Website") + dd a').first();
|
|
167
|
+
website = await websiteLink.getAttribute('href', { timeout: 2000 });
|
|
168
|
+
} catch {
|
|
169
|
+
// Try alternative selector
|
|
170
|
+
website = await extractHref(this.page, SELECTORS.company.website);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Extract from definition list
|
|
174
|
+
const industry = definitions.get('industry') || null;
|
|
175
|
+
const company_size = definitions.get('company size') || definitions.get('employees') || null;
|
|
176
|
+
const headquarters = definitions.get('headquarters') || null;
|
|
177
|
+
const founded = definitions.get('founded') || null;
|
|
178
|
+
const specialtiesRaw = definitions.get('specialties') || null;
|
|
179
|
+
const type = definitions.get('company type') || definitions.get('type') || null;
|
|
180
|
+
|
|
181
|
+
// Extract follower count from top card
|
|
182
|
+
let followerRaw: string | null = null;
|
|
183
|
+
try {
|
|
184
|
+
// Look for text containing "followers"
|
|
185
|
+
const followerText = await this.page
|
|
186
|
+
.locator('.org-top-card-summary__followers, span.org-top-card-summary__followers')
|
|
187
|
+
.first()
|
|
188
|
+
.textContent({ timeout: 3000 });
|
|
189
|
+
followerRaw = followerText;
|
|
190
|
+
} catch {
|
|
191
|
+
// Fallback: look for any span with "followers" text
|
|
192
|
+
try {
|
|
193
|
+
const spans = await this.page.locator('span').all();
|
|
194
|
+
for (const span of spans) {
|
|
195
|
+
const text = await span.textContent();
|
|
196
|
+
if (text && text.toLowerCase().includes('followers')) {
|
|
197
|
+
followerRaw = text;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// Ignore
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Build profile object
|
|
207
|
+
const profile: CompanyProfile = {
|
|
208
|
+
name: name || '',
|
|
209
|
+
linkedin_url: url,
|
|
210
|
+
website,
|
|
211
|
+
industry,
|
|
212
|
+
company_size,
|
|
213
|
+
headquarters,
|
|
214
|
+
founded,
|
|
215
|
+
specialties: parseSpecialties(specialtiesRaw),
|
|
216
|
+
type,
|
|
217
|
+
follower_count: parseFollowerCount(followerRaw),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return profile;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import type { BrowserController } from '../core/browser';
|
|
2
|
+
import { SELECTORS } from './selectors';
|
|
3
|
+
|
|
4
|
+
export interface ConnectionResult {
|
|
5
|
+
success: boolean;
|
|
6
|
+
error?: string;
|
|
7
|
+
sent?: boolean;
|
|
8
|
+
pending?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ConnectionOptions {
|
|
12
|
+
profileUrl: string;
|
|
13
|
+
note?: string;
|
|
14
|
+
skipNote?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* LinkedInConnector handles sending connection requests to LinkedIn profiles.
|
|
19
|
+
* It supports both direct Connect buttons and Connect options within the More menu.
|
|
20
|
+
*/
|
|
21
|
+
export class LinkedInConnector {
|
|
22
|
+
private browser: BrowserController;
|
|
23
|
+
|
|
24
|
+
constructor(browser: BrowserController) {
|
|
25
|
+
this.browser = browser;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Main method to send a connection request to a LinkedIn profile.
|
|
30
|
+
* Handles navigation, finding the connect button, adding notes, and sending.
|
|
31
|
+
*/
|
|
32
|
+
async connect(options: ConnectionOptions): Promise<ConnectionResult> {
|
|
33
|
+
const page = this.browser.getPage();
|
|
34
|
+
if (!page) {
|
|
35
|
+
return { success: false, error: 'Browser not initialized' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
// Navigate to profile
|
|
40
|
+
console.log(`Navigating to profile: ${options.profileUrl}`);
|
|
41
|
+
await page.goto(options.profileUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
42
|
+
await page.waitForTimeout(5000);
|
|
43
|
+
|
|
44
|
+
// Check current connection status
|
|
45
|
+
const status = await this.checkConnectionStatus();
|
|
46
|
+
console.log(`Connection status: ${status}`);
|
|
47
|
+
|
|
48
|
+
if (status === 'connected') {
|
|
49
|
+
return { success: true, sent: false, pending: false, error: 'Already connected' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (status === 'pending') {
|
|
53
|
+
return {
|
|
54
|
+
success: true,
|
|
55
|
+
sent: false,
|
|
56
|
+
pending: true,
|
|
57
|
+
error: 'Connection request already pending',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Find and click the connect button
|
|
62
|
+
const connectButton = await this.findConnectButton();
|
|
63
|
+
if (!connectButton) {
|
|
64
|
+
return { success: false, error: 'Could not find Connect button' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(`Found connect button using selector: ${connectButton.selectorUsed}`);
|
|
68
|
+
// Use force click to avoid interception by other elements
|
|
69
|
+
await connectButton.element.click({ force: true });
|
|
70
|
+
console.log('Clicked Connect button, waiting for modal...');
|
|
71
|
+
await page.waitForTimeout(3000);
|
|
72
|
+
|
|
73
|
+
// Handle "Add a note" modal if note is provided
|
|
74
|
+
if (options.note && !options.skipNote) {
|
|
75
|
+
const addNoteResult = await this.findElementWithFallbacks(
|
|
76
|
+
SELECTORS.connection.addNoteButton,
|
|
77
|
+
'Add a note button'
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (addNoteResult) {
|
|
81
|
+
console.log('Clicking "Add a note" button');
|
|
82
|
+
await addNoteResult.click();
|
|
83
|
+
await page.waitForTimeout(500);
|
|
84
|
+
|
|
85
|
+
// Find and fill the note textarea
|
|
86
|
+
const noteTextareaResult = await this.findElementWithFallbacks(
|
|
87
|
+
SELECTORS.connection.noteTextarea,
|
|
88
|
+
'Note textarea'
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (noteTextareaResult) {
|
|
92
|
+
console.log('Adding note to connection request');
|
|
93
|
+
await noteTextareaResult.fill(options.note);
|
|
94
|
+
await page.waitForTimeout(500);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Click the Send button
|
|
100
|
+
console.log('Looking for Send button in modal...');
|
|
101
|
+
|
|
102
|
+
// Wait a moment for the modal to fully render
|
|
103
|
+
await page.waitForTimeout(2000);
|
|
104
|
+
|
|
105
|
+
// Try to find Send button with multiple strategies
|
|
106
|
+
let sendButton = null;
|
|
107
|
+
|
|
108
|
+
// Strategy 1: Try primary button in modal
|
|
109
|
+
const modalButtons = await page.$$('.artdeco-modal button, [role="dialog"] button');
|
|
110
|
+
console.log(`Found ${modalButtons.length} buttons in modal/dialog`);
|
|
111
|
+
|
|
112
|
+
for (const button of modalButtons) {
|
|
113
|
+
const text = await button.textContent();
|
|
114
|
+
const ariaLabel = await button.getAttribute('aria-label');
|
|
115
|
+
const isPrimary = await button.evaluate(
|
|
116
|
+
(el) =>
|
|
117
|
+
el.classList.contains('artdeco-button--primary') || el.getAttribute('type') === 'submit'
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
console.log(` Button: text="${text?.trim()}", aria="${ariaLabel}", primary=${isPrimary}`);
|
|
121
|
+
|
|
122
|
+
// Look for Send, Connect, or primary action button
|
|
123
|
+
if (
|
|
124
|
+
text?.match(/send|connect/i) ||
|
|
125
|
+
ariaLabel?.match(/send|connect/i) ||
|
|
126
|
+
(isPrimary && text?.length && text.length < 20)
|
|
127
|
+
) {
|
|
128
|
+
sendButton = button;
|
|
129
|
+
console.log(` -> Selected as send button`);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Strategy 2: Fallback to original selector-based approach
|
|
135
|
+
if (!sendButton) {
|
|
136
|
+
console.log('Falling back to selector-based approach...');
|
|
137
|
+
const sendButtonResult = await this.findElementWithFallbacks(
|
|
138
|
+
SELECTORS.connection.sendButton,
|
|
139
|
+
'Send button'
|
|
140
|
+
);
|
|
141
|
+
if (sendButtonResult) {
|
|
142
|
+
sendButton = sendButtonResult;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!sendButton) {
|
|
147
|
+
return { success: false, error: 'Could not find Send button' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log('Clicking Send button');
|
|
151
|
+
await sendButton.click();
|
|
152
|
+
await page.waitForTimeout(2000);
|
|
153
|
+
|
|
154
|
+
// Verify the request was sent (check for success indicators or absence of error)
|
|
155
|
+
const errorMessage = await page.$(
|
|
156
|
+
'[role="alert"], .artdeco-inline-feedback--error, [data-testid="error-message"]'
|
|
157
|
+
);
|
|
158
|
+
if (errorMessage) {
|
|
159
|
+
const errorText = await errorMessage.textContent();
|
|
160
|
+
return { success: false, error: errorText || 'Unknown error occurred' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { success: true, sent: true, pending: true };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
166
|
+
console.error('Connection request failed:', errorMessage);
|
|
167
|
+
return { success: false, error: errorMessage };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Finds the Connect button on a profile page.
|
|
173
|
+
* Tries direct Connect button first, then looks in the More actions menu.
|
|
174
|
+
* Returns the element and which selector was used, or null if not found.
|
|
175
|
+
*/
|
|
176
|
+
async findConnectButton(): Promise<{
|
|
177
|
+
element: import('playwright').ElementHandle;
|
|
178
|
+
selectorUsed: string;
|
|
179
|
+
} | null> {
|
|
180
|
+
const page = this.browser.getPage();
|
|
181
|
+
if (!page) return null;
|
|
182
|
+
|
|
183
|
+
// First, try direct Connect button
|
|
184
|
+
console.log('Looking for direct Connect button...');
|
|
185
|
+
const directConnect = await this.findElementWithFallbacks(
|
|
186
|
+
SELECTORS.connection.connectButton,
|
|
187
|
+
'Connect button'
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
if (directConnect) {
|
|
191
|
+
console.log('Found direct Connect button');
|
|
192
|
+
return { element: directConnect, selectorUsed: 'direct-connect' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// If no direct button, look for "More actions" menu
|
|
196
|
+
console.log('No direct Connect button found, looking for More actions menu...');
|
|
197
|
+
const moreActions = await this.findElementWithFallbacks(
|
|
198
|
+
SELECTORS.connection.moreActionsButton,
|
|
199
|
+
'More actions button'
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
if (!moreActions) {
|
|
203
|
+
console.log('Could not find More actions button');
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Click More actions to open dropdown
|
|
208
|
+
console.log('Clicking More actions button');
|
|
209
|
+
await moreActions.click();
|
|
210
|
+
await page.waitForTimeout(1000);
|
|
211
|
+
|
|
212
|
+
// Look for Connect option in the dropdown
|
|
213
|
+
console.log('Looking for Connect option in dropdown...');
|
|
214
|
+
const connectOption = await this.findElementWithFallbacks(
|
|
215
|
+
SELECTORS.connection.connectOptionInMenu,
|
|
216
|
+
'Connect option in menu'
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (connectOption) {
|
|
220
|
+
console.log('Found Connect option in dropdown');
|
|
221
|
+
return { element: connectOption, selectorUsed: 'more-menu' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Close the dropdown by pressing Escape
|
|
225
|
+
await page.keyboard.press('Escape');
|
|
226
|
+
await page.waitForTimeout(500);
|
|
227
|
+
|
|
228
|
+
console.log('Could not find Connect option in dropdown');
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Checks the current connection status with a profile.
|
|
234
|
+
* Returns: 'connected', 'pending', 'none', or 'unknown'
|
|
235
|
+
*/
|
|
236
|
+
async checkConnectionStatus(): Promise<'connected' | 'pending' | 'none' | 'unknown'> {
|
|
237
|
+
const page = this.browser.getPage();
|
|
238
|
+
if (!page) return 'unknown';
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
// IMPORTANT: Check for Pending and Message FIRST, before looking for Connect buttons
|
|
242
|
+
// This avoids finding "Connect" buttons on other people's profiles in the sidebar
|
|
243
|
+
// when the actual profile status is "Pending" or "Connected"
|
|
244
|
+
|
|
245
|
+
// Check for "Pending" button/text (request already sent)
|
|
246
|
+
// LinkedIn shows "Pending" as a button when connection is pending
|
|
247
|
+
// IMPORTANT: Use a more specific selector that only matches in the main profile header
|
|
248
|
+
const pendingButton = await page.$('section.artdeco-card button:has-text("Pending")');
|
|
249
|
+
if (pendingButton) {
|
|
250
|
+
const isVisible = await pendingButton.isVisible().catch(() => false);
|
|
251
|
+
if (isVisible) {
|
|
252
|
+
return 'pending';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Check for "Message" button (already connected)
|
|
257
|
+
// Only check in the main profile header, not dropdown menus
|
|
258
|
+
const messageButton = await page.$('section.artdeco-card button:has-text("Message")');
|
|
259
|
+
if (messageButton) {
|
|
260
|
+
const isVisible = await messageButton.isVisible().catch(() => false);
|
|
261
|
+
if (isVisible) {
|
|
262
|
+
return 'connected';
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Now check if there's a Connect button (meaning NOT connected)
|
|
267
|
+
const connectButton = await this.findElementWithFallbacks(
|
|
268
|
+
SELECTORS.connection.connectButton,
|
|
269
|
+
'Connect button for status check'
|
|
270
|
+
);
|
|
271
|
+
if (connectButton) {
|
|
272
|
+
return 'none'; // Can connect - not connected yet
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Check for "More" menu with Connect inside (also means NOT connected)
|
|
276
|
+
const moreActions = await this.findElementWithFallbacks(
|
|
277
|
+
SELECTORS.connection.moreActionsButton,
|
|
278
|
+
'More actions button for status check'
|
|
279
|
+
);
|
|
280
|
+
if (moreActions) {
|
|
281
|
+
// Check if there's a Connect option in the dropdown
|
|
282
|
+
await moreActions.click();
|
|
283
|
+
await page.waitForTimeout(500);
|
|
284
|
+
|
|
285
|
+
const connectOption = await this.findElementWithFallbacks(
|
|
286
|
+
SELECTORS.connection.connectOptionInMenu,
|
|
287
|
+
'Connect option in More menu'
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// Close the dropdown
|
|
291
|
+
await page.keyboard.press('Escape');
|
|
292
|
+
await page.waitForTimeout(300);
|
|
293
|
+
|
|
294
|
+
if (connectOption) {
|
|
295
|
+
return 'none'; // Can connect via More menu
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return 'unknown';
|
|
300
|
+
} catch (error) {
|
|
301
|
+
console.error('Error checking connection status:', error);
|
|
302
|
+
return 'unknown';
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Helper method to try multiple selectors and return the first matching element.
|
|
308
|
+
* Logs which selector was used for debugging purposes.
|
|
309
|
+
*/
|
|
310
|
+
private async findElementWithFallbacks(
|
|
311
|
+
selectors: readonly string[],
|
|
312
|
+
elementName: string
|
|
313
|
+
): Promise<import('playwright').ElementHandle | null> {
|
|
314
|
+
const page = this.browser.getPage();
|
|
315
|
+
if (!page) return null;
|
|
316
|
+
|
|
317
|
+
for (const selector of selectors) {
|
|
318
|
+
try {
|
|
319
|
+
const element = await page.$(selector);
|
|
320
|
+
if (element) {
|
|
321
|
+
const isVisible = await element.isVisible().catch(() => false);
|
|
322
|
+
if (isVisible) {
|
|
323
|
+
console.log(`Found ${elementName} using selector: ${selector}`);
|
|
324
|
+
return element;
|
|
325
|
+
}
|
|
326
|
+
await element.dispose();
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
// Continue to next selector
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
console.log(`Could not find ${elementName} with any selector`);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import { SelectorEngine } from './selector-engine';
|
|
3
|
+
|
|
4
|
+
export interface SendMessageOptions {
|
|
5
|
+
profileUrl: string;
|
|
6
|
+
text: string;
|
|
7
|
+
dryRun?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SendMessageResult {
|
|
11
|
+
success: boolean;
|
|
12
|
+
messageId?: string;
|
|
13
|
+
threadId?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class LinkedInMessageSender {
|
|
18
|
+
private page: Page;
|
|
19
|
+
private selectorEngine: SelectorEngine;
|
|
20
|
+
private linkedInDomain: string = 'https://www.linkedin.com';
|
|
21
|
+
|
|
22
|
+
constructor(page: Page) {
|
|
23
|
+
this.page = page;
|
|
24
|
+
this.selectorEngine = new SelectorEngine(page);
|
|
25
|
+
// Always use linkedin.com for messaging (linkedin.cn has limited functionality)
|
|
26
|
+
// The browser session cookies will still work across domains
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Send a message to a LinkedIn profile
|
|
31
|
+
*/
|
|
32
|
+
async sendMessage(options: SendMessageOptions): Promise<SendMessageResult> {
|
|
33
|
+
try {
|
|
34
|
+
// Navigate to messaging page directly (works on both domains)
|
|
35
|
+
const messagingUrl = `${this.linkedInDomain}/messaging/`;
|
|
36
|
+
console.log(`Navigating to messaging page: ${messagingUrl}`);
|
|
37
|
+
|
|
38
|
+
await this.page.goto(messagingUrl, {
|
|
39
|
+
waitUntil: 'domcontentloaded',
|
|
40
|
+
timeout: 30000,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Wait for page to load
|
|
44
|
+
await this.page.waitForTimeout(3000);
|
|
45
|
+
|
|
46
|
+
// Extract profile ID from URL
|
|
47
|
+
const profileId = this.extractProfileId(options.profileUrl);
|
|
48
|
+
|
|
49
|
+
// Navigate to compose URL with recipient
|
|
50
|
+
const composeUrl = `${this.linkedInDomain}/messaging/compose/?recipient=${profileId}`;
|
|
51
|
+
console.log(`Navigating to compose URL with recipient: ${profileId}`);
|
|
52
|
+
|
|
53
|
+
await this.page.goto(composeUrl, {
|
|
54
|
+
waitUntil: 'domcontentloaded',
|
|
55
|
+
timeout: 30000,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Wait for message input to appear
|
|
59
|
+
await this.page.waitForTimeout(3000);
|
|
60
|
+
|
|
61
|
+
// Wait for message input field
|
|
62
|
+
const inputResult = await this.selectorEngine.findElement('messages', 'messageInput', {
|
|
63
|
+
timeout: 10000,
|
|
64
|
+
visible: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!inputResult.element) {
|
|
68
|
+
return { success: false, error: 'Could not find message input field' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Type the message
|
|
72
|
+
await inputResult.element.fill(options.text);
|
|
73
|
+
|
|
74
|
+
// Check if dry run
|
|
75
|
+
if (options.dryRun) {
|
|
76
|
+
return {
|
|
77
|
+
success: true,
|
|
78
|
+
messageId: `dry-run-${Date.now()}`,
|
|
79
|
+
threadId: this.extractThreadId(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Click send button
|
|
84
|
+
const sendResult = await this.selectorEngine.findElement('messages', 'sendButton', {
|
|
85
|
+
timeout: 5000,
|
|
86
|
+
visible: true,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!sendResult.element) {
|
|
90
|
+
return { success: false, error: 'Could not find send button' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await sendResult.element.click();
|
|
94
|
+
|
|
95
|
+
// Wait for message to be sent
|
|
96
|
+
await this.page.waitForTimeout(1000);
|
|
97
|
+
|
|
98
|
+
// Try to get message ID
|
|
99
|
+
const messageId = await this.page.evaluate(() => {
|
|
100
|
+
const lastMessage = document.querySelector('[data-urn*="urn:li:fsd_message:"]');
|
|
101
|
+
if (lastMessage) {
|
|
102
|
+
const urn = lastMessage.getAttribute('data-urn');
|
|
103
|
+
if (urn) {
|
|
104
|
+
const match = urn.match(/urn:li:fsd_message:(\d+)/);
|
|
105
|
+
return match ? match[1] : urn;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return `msg-${Date.now()}`;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
success: true,
|
|
113
|
+
messageId,
|
|
114
|
+
threadId: this.extractThreadId(),
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return {
|
|
118
|
+
success: false,
|
|
119
|
+
error: error instanceof Error ? error.message : 'Unknown error sending message',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Extract profile ID from LinkedIn profile URL
|
|
126
|
+
* e.g., https://www.linkedin.com/in/lily-q-7145971b9/ -> lily-q-7145971b9
|
|
127
|
+
*/
|
|
128
|
+
private extractProfileId(profileUrl: string): string {
|
|
129
|
+
const match = profileUrl.match(/\/in\/([^/?]+)/);
|
|
130
|
+
return match ? match[1] : '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Extract thread ID from current URL
|
|
135
|
+
*/
|
|
136
|
+
private extractThreadId(): string | undefined {
|
|
137
|
+
const url = this.page.url();
|
|
138
|
+
const match = url.match(/\/messaging\/thread\/([^/]+)/);
|
|
139
|
+
return match ? match[1] : undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|