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.
Files changed (314) hide show
  1. package/.env.example +12 -0
  2. package/.github/workflows/ci.yml +66 -0
  3. package/.github/workflows/publish.yml +48 -0
  4. package/.husky/pre-commit +6 -0
  5. package/.prettierignore +4 -0
  6. package/.prettierrc +10 -0
  7. package/AGENTS.md +294 -0
  8. package/CHANGELOG.md +40 -0
  9. package/GIT_RELEASE.md +167 -0
  10. package/LICENSE +21 -0
  11. package/Makefile +30 -0
  12. package/NPM_PUBLISHING.md +230 -0
  13. package/PYEOF +0 -0
  14. package/README.md +295 -0
  15. package/TESTING-GUIDE.md +151 -0
  16. package/cmd/linkedin/main.go +9 -0
  17. package/dist/agent/action-executor.d.ts +81 -0
  18. package/dist/agent/action-executor.d.ts.map +1 -0
  19. package/dist/agent/action-executor.js +170 -0
  20. package/dist/agent/action-executor.js.map +1 -0
  21. package/dist/agent/action-executor.test.d.ts +2 -0
  22. package/dist/agent/action-executor.test.d.ts.map +1 -0
  23. package/dist/agent/action-executor.test.js +366 -0
  24. package/dist/agent/action-executor.test.js.map +1 -0
  25. package/dist/agent/claude-client.d.ts +74 -0
  26. package/dist/agent/claude-client.d.ts.map +1 -0
  27. package/dist/agent/claude-client.js +314 -0
  28. package/dist/agent/claude-client.js.map +1 -0
  29. package/dist/agent/claude-client.test.d.ts +2 -0
  30. package/dist/agent/claude-client.test.d.ts.map +1 -0
  31. package/dist/agent/claude-client.test.js +590 -0
  32. package/dist/agent/claude-client.test.js.map +1 -0
  33. package/dist/agent/dom-extractor.d.ts +50 -0
  34. package/dist/agent/dom-extractor.d.ts.map +1 -0
  35. package/dist/agent/dom-extractor.js +374 -0
  36. package/dist/agent/dom-extractor.js.map +1 -0
  37. package/dist/agent/dom-extractor.test.d.ts +7 -0
  38. package/dist/agent/dom-extractor.test.d.ts.map +1 -0
  39. package/dist/agent/dom-extractor.test.js +504 -0
  40. package/dist/agent/dom-extractor.test.js.map +1 -0
  41. package/dist/agent/extension-client.d.ts +75 -0
  42. package/dist/agent/extension-client.d.ts.map +1 -0
  43. package/dist/agent/extension-client.js +245 -0
  44. package/dist/agent/extension-client.js.map +1 -0
  45. package/dist/agent/index.d.ts +8 -0
  46. package/dist/agent/index.d.ts.map +1 -0
  47. package/dist/agent/index.js +16 -0
  48. package/dist/agent/index.js.map +1 -0
  49. package/dist/agent/page-agent.d.ts +76 -0
  50. package/dist/agent/page-agent.d.ts.map +1 -0
  51. package/dist/agent/page-agent.js +236 -0
  52. package/dist/agent/page-agent.js.map +1 -0
  53. package/dist/agent/types.d.ts +236 -0
  54. package/dist/agent/types.d.ts.map +1 -0
  55. package/dist/agent/types.js +37 -0
  56. package/dist/agent/types.js.map +1 -0
  57. package/dist/cli/agent-commands.d.ts +3 -0
  58. package/dist/cli/agent-commands.d.ts.map +1 -0
  59. package/dist/cli/agent-commands.js +250 -0
  60. package/dist/cli/agent-commands.js.map +1 -0
  61. package/dist/cli/auth.d.ts +3 -0
  62. package/dist/cli/auth.d.ts.map +1 -0
  63. package/dist/cli/auth.js +288 -0
  64. package/dist/cli/auth.js.map +1 -0
  65. package/dist/cli/company.d.ts +3 -0
  66. package/dist/cli/company.d.ts.map +1 -0
  67. package/dist/cli/company.js +55 -0
  68. package/dist/cli/company.js.map +1 -0
  69. package/dist/cli/connection.d.ts +3 -0
  70. package/dist/cli/connection.d.ts.map +1 -0
  71. package/dist/cli/connection.js +79 -0
  72. package/dist/cli/connection.js.map +1 -0
  73. package/dist/cli/index.d.ts +7 -0
  74. package/dist/cli/index.d.ts.map +1 -0
  75. package/dist/cli/index.js +17 -0
  76. package/dist/cli/index.js.map +1 -0
  77. package/dist/cli/messages.d.ts +3 -0
  78. package/dist/cli/messages.d.ts.map +1 -0
  79. package/dist/cli/messages.js +268 -0
  80. package/dist/cli/messages.js.map +1 -0
  81. package/dist/cli/profile.d.ts +3 -0
  82. package/dist/cli/profile.d.ts.map +1 -0
  83. package/dist/cli/profile.js +81 -0
  84. package/dist/cli/profile.js.map +1 -0
  85. package/dist/cli/profile.test.d.ts +2 -0
  86. package/dist/cli/profile.test.d.ts.map +1 -0
  87. package/dist/cli/profile.test.js +15 -0
  88. package/dist/cli/profile.test.js.map +1 -0
  89. package/dist/cli/reply.d.ts +3 -0
  90. package/dist/cli/reply.d.ts.map +1 -0
  91. package/dist/cli/reply.js +129 -0
  92. package/dist/cli/reply.js.map +1 -0
  93. package/dist/core/audit.d.ts +17 -0
  94. package/dist/core/audit.d.ts.map +1 -0
  95. package/dist/core/audit.js +121 -0
  96. package/dist/core/audit.js.map +1 -0
  97. package/dist/core/audit.test.d.ts +2 -0
  98. package/dist/core/audit.test.d.ts.map +1 -0
  99. package/dist/core/audit.test.js +142 -0
  100. package/dist/core/audit.test.js.map +1 -0
  101. package/dist/core/browser-cookies.d.ts +19 -0
  102. package/dist/core/browser-cookies.d.ts.map +1 -0
  103. package/dist/core/browser-cookies.js +181 -0
  104. package/dist/core/browser-cookies.js.map +1 -0
  105. package/dist/core/browser.d.ts +50 -0
  106. package/dist/core/browser.d.ts.map +1 -0
  107. package/dist/core/browser.js +318 -0
  108. package/dist/core/browser.js.map +1 -0
  109. package/dist/core/config.d.ts +20 -0
  110. package/dist/core/config.d.ts.map +1 -0
  111. package/dist/core/config.js +103 -0
  112. package/dist/core/config.js.map +1 -0
  113. package/dist/core/config.test.d.ts +2 -0
  114. package/dist/core/config.test.d.ts.map +1 -0
  115. package/dist/core/config.test.js +111 -0
  116. package/dist/core/config.test.js.map +1 -0
  117. package/dist/core/storage.d.ts +19 -0
  118. package/dist/core/storage.d.ts.map +1 -0
  119. package/dist/core/storage.js +124 -0
  120. package/dist/core/storage.js.map +1 -0
  121. package/dist/core/storage.test.d.ts +2 -0
  122. package/dist/core/storage.test.d.ts.map +1 -0
  123. package/dist/core/storage.test.js +142 -0
  124. package/dist/core/storage.test.js.map +1 -0
  125. package/dist/index.d.ts +3 -0
  126. package/dist/index.d.ts.map +1 -0
  127. package/dist/index.js +63 -0
  128. package/dist/index.js.map +1 -0
  129. package/dist/linkedin/auth.d.ts +22 -0
  130. package/dist/linkedin/auth.d.ts.map +1 -0
  131. package/dist/linkedin/auth.js +167 -0
  132. package/dist/linkedin/auth.js.map +1 -0
  133. package/dist/linkedin/company-extractor.d.ts +36 -0
  134. package/dist/linkedin/company-extractor.d.ts.map +1 -0
  135. package/dist/linkedin/company-extractor.js +211 -0
  136. package/dist/linkedin/company-extractor.js.map +1 -0
  137. package/dist/linkedin/company-extractor.test.d.ts +2 -0
  138. package/dist/linkedin/company-extractor.test.d.ts.map +1 -0
  139. package/dist/linkedin/company-extractor.test.js +52 -0
  140. package/dist/linkedin/company-extractor.test.js.map +1 -0
  141. package/dist/linkedin/connector.d.ts +45 -0
  142. package/dist/linkedin/connector.d.ts.map +1 -0
  143. package/dist/linkedin/connector.js +245 -0
  144. package/dist/linkedin/connector.js.map +1 -0
  145. package/dist/linkedin/message-sender.d.ts +32 -0
  146. package/dist/linkedin/message-sender.d.ts.map +1 -0
  147. package/dist/linkedin/message-sender.js +112 -0
  148. package/dist/linkedin/message-sender.js.map +1 -0
  149. package/dist/linkedin/messages.d.ts +78 -0
  150. package/dist/linkedin/messages.d.ts.map +1 -0
  151. package/dist/linkedin/messages.js +745 -0
  152. package/dist/linkedin/messages.js.map +1 -0
  153. package/dist/linkedin/profile.d.ts +37 -0
  154. package/dist/linkedin/profile.d.ts.map +1 -0
  155. package/dist/linkedin/profile.js +268 -0
  156. package/dist/linkedin/profile.js.map +1 -0
  157. package/dist/linkedin/profile.test.d.ts +2 -0
  158. package/dist/linkedin/profile.test.d.ts.map +1 -0
  159. package/dist/linkedin/profile.test.js +68 -0
  160. package/dist/linkedin/profile.test.js.map +1 -0
  161. package/dist/linkedin/reply.d.ts +21 -0
  162. package/dist/linkedin/reply.d.ts.map +1 -0
  163. package/dist/linkedin/reply.js +76 -0
  164. package/dist/linkedin/reply.js.map +1 -0
  165. package/dist/linkedin/selector-engine.d.ts +69 -0
  166. package/dist/linkedin/selector-engine.d.ts.map +1 -0
  167. package/dist/linkedin/selector-engine.js +339 -0
  168. package/dist/linkedin/selector-engine.js.map +1 -0
  169. package/dist/linkedin/selector-engine.test.d.ts +2 -0
  170. package/dist/linkedin/selector-engine.test.d.ts.map +1 -0
  171. package/dist/linkedin/selector-engine.test.js +135 -0
  172. package/dist/linkedin/selector-engine.test.js.map +1 -0
  173. package/dist/linkedin/selectors.d.ts +65 -0
  174. package/dist/linkedin/selectors.d.ts.map +1 -0
  175. package/dist/linkedin/selectors.js +261 -0
  176. package/dist/linkedin/selectors.js.map +1 -0
  177. package/dist/templates/engine.d.ts +37 -0
  178. package/dist/templates/engine.d.ts.map +1 -0
  179. package/dist/templates/engine.js +215 -0
  180. package/dist/templates/engine.js.map +1 -0
  181. package/dist/templates/engine.test.d.ts +2 -0
  182. package/dist/templates/engine.test.d.ts.map +1 -0
  183. package/dist/templates/engine.test.js +212 -0
  184. package/dist/templates/engine.test.js.map +1 -0
  185. package/dist/templates/index.d.ts +2 -0
  186. package/dist/templates/index.d.ts.map +1 -0
  187. package/dist/templates/index.js +7 -0
  188. package/dist/templates/index.js.map +1 -0
  189. package/dist/types/index.d.ts +113 -0
  190. package/dist/types/index.d.ts.map +1 -0
  191. package/dist/types/index.js +3 -0
  192. package/dist/types/index.js.map +1 -0
  193. package/dist/types/index.test.d.ts +2 -0
  194. package/dist/types/index.test.d.ts.map +1 -0
  195. package/dist/types/index.test.js +90 -0
  196. package/dist/types/index.test.js.map +1 -0
  197. package/dist/utils/paths.d.ts +8 -0
  198. package/dist/utils/paths.d.ts.map +1 -0
  199. package/dist/utils/paths.js +68 -0
  200. package/dist/utils/paths.js.map +1 -0
  201. package/dist/utils/rate-limiter.d.ts +22 -0
  202. package/dist/utils/rate-limiter.d.ts.map +1 -0
  203. package/dist/utils/rate-limiter.js +57 -0
  204. package/dist/utils/rate-limiter.js.map +1 -0
  205. package/dist/utils/retry.d.ts +18 -0
  206. package/dist/utils/retry.d.ts.map +1 -0
  207. package/dist/utils/retry.js +49 -0
  208. package/dist/utils/retry.js.map +1 -0
  209. package/docs/connection-command.md +52 -0
  210. package/docs/plans/2025-03-03-linkedin-cli-design.md +280 -0
  211. package/docs/plans/2025-03-03-linkedin-cli-implementation-plan.md +2087 -0
  212. package/docs/plans/2025-03-03-linkedin-cli-implementation.md +2420 -0
  213. package/docs/plans/2026-02-19-linkedin-connection-feature.md +596 -0
  214. package/docs/plans/2026-02-28-messages-send-feature.md +480 -0
  215. package/docs/plans/2026-02-28-messages-show-design.md +243 -0
  216. package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-design.md +394 -0
  217. package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-plan.md +1592 -0
  218. package/docs/superpowers/plans/2026-03-13-linkedin-automation-resilience-migration.md +425 -0
  219. package/docs/superpowers/plans/2026-03-13-playwright-fara-migration.md +1112 -0
  220. package/docs/superpowers/plans/2026-03-14-page-agent-plan.md +1598 -0
  221. package/docs/superpowers/plans/2026-03-15-company-profile-extraction.md +591 -0
  222. package/docs/superpowers/plans/2026-03-15-profile-extraction-plan.md +943 -0
  223. package/docs/superpowers/specs/2026-03-14-company-profile-extraction-design.md +371 -0
  224. package/docs/superpowers/specs/2026-03-14-page-agent-design.md +385 -0
  225. package/docs/superpowers/specs/2026-03-15-profile-extraction-design.md +409 -0
  226. package/eslint.config.mjs +58 -0
  227. package/go.mod +9 -0
  228. package/go.sum +10 -0
  229. package/import-cookies.js +376 -0
  230. package/internal/cmd/actions.go +123 -0
  231. package/internal/cmd/auth.go +108 -0
  232. package/internal/cmd/connect.go +42 -0
  233. package/internal/cmd/message.go +44 -0
  234. package/internal/cmd/people.go +454 -0
  235. package/internal/cmd/profiles.go +121 -0
  236. package/internal/cmd/root.go +89 -0
  237. package/internal/cmd/sequence.go +192 -0
  238. package/internal/config/config.go +187 -0
  239. package/internal/config/config_test.go +121 -0
  240. package/internal/config/profile.go +65 -0
  241. package/internal/linkedin/navigator.go +195 -0
  242. package/internal/linkedin/selectors.go +39 -0
  243. package/internal/linkedin/validator.go +69 -0
  244. package/internal/pinchtab/client.go +183 -0
  245. package/internal/pinchtab/client_test.go +67 -0
  246. package/internal/pinchtab/types.go +50 -0
  247. package/internal/ratelimit/limiter.go +115 -0
  248. package/internal/ratelimit/limits.go +32 -0
  249. package/package.json +67 -0
  250. package/release.sh +66 -0
  251. package/scripts/debug-linkedin.js +156 -0
  252. package/scripts/debug-login.js +193 -0
  253. package/scripts/extract-from-edge.js +96 -0
  254. package/scripts/import-cookies.js +101 -0
  255. package/scripts/poc-show-data.js +205 -0
  256. package/scripts/proof-of-access.js +87 -0
  257. package/scripts/prove-connection.js +110 -0
  258. package/scripts/show-linkedin-data.js +173 -0
  259. package/src/agent/action-executor.test.ts +464 -0
  260. package/src/agent/action-executor.ts +203 -0
  261. package/src/agent/claude-client.test.ts +707 -0
  262. package/src/agent/claude-client.ts +422 -0
  263. package/src/agent/dom-extractor.test.ts +574 -0
  264. package/src/agent/dom-extractor.ts +437 -0
  265. package/src/agent/extension-client.ts +306 -0
  266. package/src/agent/index.ts +28 -0
  267. package/src/agent/page-agent.ts +292 -0
  268. package/src/agent/types.ts +288 -0
  269. package/src/cli/agent-commands.ts +274 -0
  270. package/src/cli/auth.ts +343 -0
  271. package/src/cli/company.ts +66 -0
  272. package/src/cli/connection.ts +89 -0
  273. package/src/cli/index.ts +7 -0
  274. package/src/cli/messages.ts +338 -0
  275. package/src/cli/profile.test.ts +14 -0
  276. package/src/cli/profile.ts +95 -0
  277. package/src/cli/reply.ts +110 -0
  278. package/src/core/audit.test.ts +134 -0
  279. package/src/core/audit.ts +98 -0
  280. package/src/core/browser-cookies.ts +203 -0
  281. package/src/core/browser.ts +304 -0
  282. package/src/core/config.test.ts +90 -0
  283. package/src/core/config.ts +81 -0
  284. package/src/core/storage.test.ts +129 -0
  285. package/src/core/storage.ts +100 -0
  286. package/src/index.ts +70 -0
  287. package/src/linkedin/auth.ts +218 -0
  288. package/src/linkedin/company-extractor.test.ts +58 -0
  289. package/src/linkedin/company-extractor.ts +222 -0
  290. package/src/linkedin/connector.ts +336 -0
  291. package/src/linkedin/message-sender.ts +141 -0
  292. package/src/linkedin/messages.ts +894 -0
  293. package/src/linkedin/profile.test.ts +79 -0
  294. package/src/linkedin/profile.ts +314 -0
  295. package/src/linkedin/reply.ts +96 -0
  296. package/src/linkedin/selector-engine.test.ts +167 -0
  297. package/src/linkedin/selector-engine.ts +393 -0
  298. package/src/linkedin/selectors.ts +268 -0
  299. package/src/templates/defaults/followup.txt +14 -0
  300. package/src/templates/defaults/meeting.txt +16 -0
  301. package/src/templates/defaults/welcome.txt +14 -0
  302. package/src/templates/engine.test.ts +228 -0
  303. package/src/templates/engine.ts +208 -0
  304. package/src/templates/index.ts +1 -0
  305. package/src/types/index.test.ts +94 -0
  306. package/src/types/index.ts +143 -0
  307. package/src/types/sql.js.d.ts +23 -0
  308. package/src/utils/paths.ts +33 -0
  309. package/src/utils/rate-limiter.ts +75 -0
  310. package/src/utils/retry.ts +78 -0
  311. package/test-cli.sh +85 -0
  312. package/test-real-data.sh +97 -0
  313. package/tsconfig.json +23 -0
  314. 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
+ }