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,79 @@
1
+ // src/linkedin/profile.test.ts
2
+ import { describe, it, expect } from 'vitest';
3
+ import {
4
+ LinkedInProfile,
5
+ validateProfileUrl,
6
+ parseDurationString,
7
+ calculateTotalExperience,
8
+ } from './profile';
9
+
10
+ describe('LinkedInProfile', () => {
11
+ it('should be defined', () => {
12
+ expect(LinkedInProfile).toBeDefined();
13
+ });
14
+ });
15
+
16
+ describe('validateProfileUrl', () => {
17
+ it('should accept valid LinkedIn profile URLs', () => {
18
+ const validUrls = [
19
+ 'https://www.linkedin.com/in/johndoe/',
20
+ 'https://linkedin.com/in/jane-doe-123/',
21
+ 'http://www.linkedin.com/in/user',
22
+ ];
23
+ validUrls.forEach((url) => {
24
+ expect(validateProfileUrl(url)).toBe(true);
25
+ });
26
+ });
27
+
28
+ it('should reject invalid URLs', () => {
29
+ const invalidUrls = [
30
+ 'https://www.google.com/',
31
+ 'https://linkedin.com/company/test',
32
+ 'not-a-url',
33
+ '',
34
+ ];
35
+ invalidUrls.forEach((url) => {
36
+ expect(validateProfileUrl(url)).toBe(false);
37
+ });
38
+ });
39
+ });
40
+
41
+ describe('parseDurationString', () => {
42
+ it('should parse "Present" duration', () => {
43
+ const result = parseDurationString('Jan 2020 - Present (5 yrs 3 mos)');
44
+ expect(result).not.toBeNull();
45
+ expect(result!.years).toBe(5);
46
+ expect(result!.endDate).toBeNull();
47
+ });
48
+
49
+ it('should parse range duration', () => {
50
+ const result = parseDurationString('Mar 2019 - Dec 2021 (2 yr 10 mos)');
51
+ expect(result).not.toBeNull();
52
+ expect(result!.years).toBe(2);
53
+ });
54
+
55
+ it('should parse year-only duration', () => {
56
+ const result = parseDurationString('2018 - 2022 (4 years)');
57
+ expect(result).not.toBeNull();
58
+ expect(result!.years).toBe(4);
59
+ });
60
+
61
+ it('should return null for invalid duration', () => {
62
+ expect(parseDurationString('invalid')).toBeNull();
63
+ expect(parseDurationString('')).toBeNull();
64
+ });
65
+ });
66
+
67
+ describe('calculateTotalExperience', () => {
68
+ it('should sum years from durations', () => {
69
+ const durations = [
70
+ { startDate: new Date(), endDate: null, years: 5 },
71
+ { startDate: new Date(), endDate: new Date(), years: 3 },
72
+ ];
73
+ expect(calculateTotalExperience(durations)).toBe(8);
74
+ });
75
+
76
+ it('should return 0 for empty array', () => {
77
+ expect(calculateTotalExperience([])).toBe(0);
78
+ });
79
+ });
@@ -0,0 +1,314 @@
1
+ // src/linkedin/profile.ts
2
+ import type { Page } from 'playwright';
3
+ import { SelectorEngine } from './selector-engine';
4
+ import { SELECTORS } from './selectors';
5
+ import type { ProfileData, ProfileExtractionOptions, ProfileExtractionResult } from '../types';
6
+
7
+ // ============================================================================
8
+ // URL Validation
9
+ // ============================================================================
10
+
11
+ const LINKEDIN_PROFILE_URL_REGEX = /^https?:\/\/(?:www\.)?linkedin\.com\/in\/[^\/]+\/?$/;
12
+
13
+ export function validateProfileUrl(url: string): boolean {
14
+ return LINKEDIN_PROFILE_URL_REGEX.test(url);
15
+ }
16
+
17
+ // ============================================================================
18
+ // Duration Parsing
19
+ // ============================================================================
20
+
21
+ interface ParsedDuration {
22
+ startDate: Date;
23
+ endDate: Date | null;
24
+ years: number;
25
+ }
26
+
27
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
28
+
29
+ function parseMonthYear(text: string): Date {
30
+ const parts = text.trim().split(/\s+/);
31
+ const monthName = parts[0];
32
+ const year = parseInt(parts[1], 10);
33
+ const month = MONTHS.indexOf(monthName);
34
+ return new Date(year, month, 1);
35
+ }
36
+
37
+ export function parseDurationString(durationText: string): ParsedDuration | null {
38
+ if (!durationText || durationText.trim().length === 0) {
39
+ return null;
40
+ }
41
+
42
+ const text = durationText.trim();
43
+
44
+ // Pattern: "Jan 2020 - Present · 5 yrs 3 mos" or "Jan 2020 - Present (5 yrs 3 mos)"
45
+ const presentPattern =
46
+ /^(\w+\s+\d{4})\s*-\s*Present\s*[·(]\s*(\d+)\s*yrs?\s*(?:\d+\s*mos?)?\s*[·)]?$/i;
47
+ // Pattern: "Mar 2019 - Dec 2021 · 2 yr 10 mos" or with parentheses
48
+ const rangePattern =
49
+ /^(\w+\s+\d{4})\s*-\s*(\w+\s+\d{4})\s*[·(]\s*(\d+)\s*(?:yrs?|years?)\s*(?:\d+\s*mos?)?\s*[·)]?$/i;
50
+ // Pattern: "2018 - 2022 · 4 years" or with parentheses
51
+ const yearOnlyPattern = /^(\d{4})\s*-\s*(\d{4})\s*[·(]\s*(\d+)\s*years?\s*[·)]?$/i;
52
+
53
+ let match = text.match(presentPattern);
54
+ if (match) {
55
+ const startDate = parseMonthYear(match[1]);
56
+ const years = parseInt(match[2], 10);
57
+ return { startDate, endDate: null, years };
58
+ }
59
+
60
+ match = text.match(rangePattern);
61
+ if (match) {
62
+ const startDate = parseMonthYear(match[1]);
63
+ const endDate = parseMonthYear(match[2]);
64
+ const years = parseInt(match[3], 10);
65
+ return { startDate, endDate, years };
66
+ }
67
+
68
+ match = text.match(yearOnlyPattern);
69
+ if (match) {
70
+ const startDate = new Date(parseInt(match[1], 10), 0, 1);
71
+ const endDate = new Date(parseInt(match[2], 10), 11, 31);
72
+ const years = parseInt(match[3], 10);
73
+ return { startDate, endDate, years };
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ export function calculateTotalExperience(durations: ParsedDuration[]): number {
80
+ return durations.reduce((total, d) => total + d.years, 0);
81
+ }
82
+
83
+ // ============================================================================
84
+ // LinkedInProfile Class
85
+ // ============================================================================
86
+
87
+ /**
88
+ * LinkedInProfile class for extracting structured data from LinkedIn profiles.
89
+ * Uses SelectorEngine with multi-layer fallbacks for resilience.
90
+ */
91
+ export class LinkedInProfile {
92
+ private page: Page;
93
+ private selectorEngine: SelectorEngine;
94
+
95
+ constructor(page: Page) {
96
+ this.page = page;
97
+ this.selectorEngine = new SelectorEngine(page);
98
+ }
99
+
100
+ /**
101
+ * Extract profile data from a LinkedIn profile URL.
102
+ */
103
+ async extract(options: ProfileExtractionOptions): Promise<ProfileExtractionResult> {
104
+ const timeout = options.timeout ?? 30000;
105
+
106
+ // Validate URL
107
+ if (!validateProfileUrl(options.profileUrl)) {
108
+ return {
109
+ success: false,
110
+ message: 'Invalid LinkedIn profile URL',
111
+ error: 'URL must match pattern: https://www.linkedin.com/in/{username}/',
112
+ };
113
+ }
114
+
115
+ try {
116
+ // Navigate to profile
117
+ await this.page.goto(options.profileUrl, {
118
+ waitUntil: 'domcontentloaded',
119
+ timeout,
120
+ });
121
+ await this.page.waitForTimeout(3000);
122
+
123
+ // Extract top card data
124
+ const fullName = await this.extractText('profile', 'name');
125
+ if (!fullName) {
126
+ return {
127
+ success: false,
128
+ message: 'Could not extract profile name',
129
+ error: 'Profile name not found on page',
130
+ };
131
+ }
132
+
133
+ const headline = await this.extractText('profile', 'headline');
134
+ const location = await this.extractText('profile', 'location');
135
+
136
+ // Extract experience data
137
+ const experienceData = await this.extractExperienceData();
138
+
139
+ // Extract contact info if requested
140
+ let email: string | null = null;
141
+ let phone: string | null = null;
142
+ if (options.includeContact) {
143
+ const contactInfo = await this.extractContactInfo();
144
+ email = contactInfo.email;
145
+ phone = contactInfo.phone;
146
+ }
147
+
148
+ const profileData: ProfileData = {
149
+ full_name: fullName,
150
+ headline: headline ?? '',
151
+ location: location ?? '',
152
+ current_company: experienceData.currentCompany,
153
+ current_title: experienceData.currentTitle,
154
+ company_linkedin_url: experienceData.companyUrl,
155
+ years_experience: experienceData.yearsExperience,
156
+ email,
157
+ phone,
158
+ profile_url: this.page.url(),
159
+ };
160
+
161
+ return {
162
+ success: true,
163
+ message: 'Profile extracted successfully',
164
+ data: profileData,
165
+ };
166
+ } catch (error) {
167
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
168
+ return {
169
+ success: false,
170
+ message: errorMessage,
171
+ error: errorMessage,
172
+ };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Extract text using selector engine with fallbacks
178
+ */
179
+ private async extractText(
180
+ category: 'profile',
181
+ key: keyof typeof SELECTORS.profile
182
+ ): Promise<string | null> {
183
+ const result = await this.selectorEngine.findElement(category, key, { timeout: 5000 });
184
+ if (!result.element) {
185
+ return null;
186
+ }
187
+ const text = await result.element.textContent();
188
+ return text?.trim() || null;
189
+ }
190
+
191
+ /**
192
+ * Extract experience data from the profile
193
+ */
194
+ private async extractExperienceData(): Promise<{
195
+ currentCompany: string | null;
196
+ currentTitle: string | null;
197
+ companyUrl: string | null;
198
+ yearsExperience: number | null;
199
+ }> {
200
+ // Find experience section by looking for section with "Experience" heading
201
+ const expSection = await this.page.locator('section:has(h2:has-text("Experience"))').first();
202
+ const sectionCount = await expSection.count();
203
+ if (sectionCount === 0) {
204
+ return { currentCompany: null, currentTitle: null, companyUrl: null, yearsExperience: null };
205
+ }
206
+
207
+ // Get first experience item (li within the section)
208
+ const firstItem = expSection.locator('li').first();
209
+ const itemCount = await firstItem.count();
210
+ if (itemCount === 0) {
211
+ return { currentCompany: null, currentTitle: null, companyUrl: null, yearsExperience: null };
212
+ }
213
+
214
+ // Extract title from .t-bold span
215
+ let currentTitle: string | null = null;
216
+ try {
217
+ const titleEl = firstItem.locator('.t-bold span[aria-hidden="true"]').first();
218
+ const titleText = await titleEl.textContent({ timeout: 2000 });
219
+ if (titleText?.trim()) {
220
+ currentTitle = titleText.trim();
221
+ }
222
+ } catch {
223
+ // Title not found
224
+ }
225
+
226
+ // Extract company from .t-14.t-normal span
227
+ // Format is typically "Company Name · Full-time" or just "Company Name"
228
+ let currentCompany: string | null = null;
229
+ try {
230
+ const companyEls = firstItem.locator('.t-14.t-normal span[aria-hidden="true"]');
231
+ const count = await companyEls.count();
232
+ for (let i = 0; i < count; i++) {
233
+ const text = await companyEls.nth(i).textContent();
234
+ if (text?.trim()) {
235
+ // Skip duration patterns (contain month abbreviations or years)
236
+ if (text.includes('Present') || /\d{4}/.test(text)) continue;
237
+ // Extract company name from "Company · Employment Type" format
238
+ const companyText = text.split('·')[0].trim();
239
+ if (companyText && !companyText.includes('yrs') && !companyText.includes('mos')) {
240
+ currentCompany = companyText;
241
+ break;
242
+ }
243
+ }
244
+ }
245
+ } catch {
246
+ // Company not found
247
+ }
248
+
249
+ // Extract company URL
250
+ let companyUrl: string | null = null;
251
+ try {
252
+ const linkEl = firstItem.locator('a[data-field="experience_company_logo"]').first();
253
+ companyUrl = await linkEl.getAttribute('href', { timeout: 2000 });
254
+ } catch {
255
+ // Company link not found
256
+ }
257
+
258
+ // Extract duration for years calculation
259
+ // Duration format: "Dec 2025 - Present · 4 mos" or "Jan 2020 - Present · 5 yrs 3 mos"
260
+ let yearsExperience: number | null = null;
261
+ try {
262
+ const durationEl = firstItem.locator('.pvs-entity__caption-wrapper').first();
263
+ const durationText = await durationEl.textContent({ timeout: 2000 });
264
+ if (durationText) {
265
+ // Parse "X yrs Y mos" or just "X mos" format
266
+ const yrsMatch = durationText.match(/(\d+)\s*yrs?/i);
267
+ const mosMatch = durationText.match(/(\d+)\s*mos?/i);
268
+ if (yrsMatch) {
269
+ yearsExperience = parseInt(yrsMatch[1], 10);
270
+ } else if (mosMatch) {
271
+ // Convert months to fractional years
272
+ yearsExperience = Math.round((parseInt(mosMatch[1], 10) / 12) * 10) / 10;
273
+ }
274
+ }
275
+ } catch {
276
+ // Duration not found
277
+ }
278
+
279
+ return { currentCompany, currentTitle, companyUrl, yearsExperience };
280
+ }
281
+
282
+ /**
283
+ * Extract contact info by clicking the contact info button
284
+ */
285
+ private async extractContactInfo(): Promise<{ email: string | null; phone: string | null }> {
286
+ // Click contact info button
287
+ const buttonResult = await this.selectorEngine.findElement('profile', 'contactInfoButton', {
288
+ timeout: 5000,
289
+ });
290
+ if (!buttonResult.element) {
291
+ return { email: null, phone: null };
292
+ }
293
+
294
+ await buttonResult.element.click();
295
+ await this.page.waitForTimeout(2000);
296
+
297
+ // Extract email and phone
298
+ const email = await this.extractText('profile', 'email');
299
+ const phone = await this.extractText('profile', 'phone');
300
+
301
+ // Close modal
302
+ const closeResult = await this.selectorEngine.findElement('profile', 'contactInfoCloseButton', {
303
+ timeout: 2000,
304
+ });
305
+ if (closeResult.element) {
306
+ await closeResult.element.click();
307
+ } else {
308
+ await this.page.keyboard.press('Escape');
309
+ }
310
+ await this.page.waitForTimeout(1000);
311
+
312
+ return { email, phone };
313
+ }
314
+ }
@@ -0,0 +1,96 @@
1
+ import type { Page } from 'playwright';
2
+ import { SelectorEngine } from './selector-engine';
3
+
4
+ export interface SendMessageOptions {
5
+ threadId: string;
6
+ text: string;
7
+ dryRun?: boolean;
8
+ }
9
+
10
+ export interface SendMessageResult {
11
+ success: boolean;
12
+ messageId?: string;
13
+ error?: string;
14
+ }
15
+
16
+ export class LinkedInReply {
17
+ private page: Page;
18
+ private selectorEngine: SelectorEngine;
19
+
20
+ constructor(page: Page) {
21
+ this.page = page;
22
+ this.selectorEngine = new SelectorEngine(page);
23
+ }
24
+
25
+ /**
26
+ * Send a message to a thread
27
+ */
28
+ async sendMessage(options: SendMessageOptions): Promise<SendMessageResult> {
29
+ try {
30
+ // Navigate to thread
31
+ await this.page.goto(`https://www.linkedin.com/messaging/thread/${options.threadId}/`, {
32
+ waitUntil: 'networkidle',
33
+ timeout: 30000,
34
+ });
35
+
36
+ // Wait for message input
37
+ const inputResult = await this.selectorEngine.findElement('messages', 'messageInput', {
38
+ timeout: 10000,
39
+ visible: true,
40
+ });
41
+
42
+ if (!inputResult.element) {
43
+ return { success: false, error: 'Could not find message input field' };
44
+ }
45
+
46
+ // Type message
47
+ await inputResult.element.fill(options.text);
48
+
49
+ // Check if dry run
50
+ if (options.dryRun) {
51
+ return {
52
+ success: true,
53
+ messageId: `dry-run-${Date.now()}`,
54
+ };
55
+ }
56
+
57
+ // Click send button
58
+ const sendResult = await this.selectorEngine.findElement('messages', 'sendButton', {
59
+ timeout: 5000,
60
+ visible: true,
61
+ });
62
+
63
+ if (!sendResult.element) {
64
+ return { success: false, error: 'Could not find send button' };
65
+ }
66
+
67
+ await sendResult.element.click();
68
+
69
+ // Wait for message to be sent (look for confirmation)
70
+ await this.page.waitForTimeout(1000);
71
+
72
+ // Try to get message ID from the DOM
73
+ const messageId = await this.page.evaluate(() => {
74
+ const lastMessage = document.querySelector('[data-urn*="urn:li:fsd_message:"]');
75
+ if (lastMessage) {
76
+ const urn = lastMessage.getAttribute('data-urn');
77
+ if (urn) {
78
+ const match = urn.match(/urn:li:fsd_message:(\d+)/);
79
+ return match ? match[1] : urn;
80
+ }
81
+ }
82
+ return `msg-${Date.now()}`;
83
+ });
84
+
85
+ return {
86
+ success: true,
87
+ messageId,
88
+ };
89
+ } catch (error) {
90
+ return {
91
+ success: false,
92
+ error: error instanceof Error ? error.message : 'Unknown error sending message',
93
+ };
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
2
+
3
+ // Mock Page object for testing - updated for current implementation
4
+ const createMockPage = () => {
5
+ const mockElementHandle = { dispose: vi.fn() };
6
+ const getByRoleMock = vi.fn().mockReturnValue({
7
+ elementHandle: vi.fn().mockResolvedValue(mockElementHandle),
8
+ });
9
+ const getByLabelMock = vi.fn().mockReturnValue({
10
+ elementHandle: vi.fn().mockResolvedValue(mockElementHandle),
11
+ });
12
+ const getByTestIdMock = vi.fn().mockReturnValue({
13
+ elementHandle: vi.fn().mockResolvedValue(mockElementHandle),
14
+ });
15
+
16
+ return {
17
+ waitForSelector: vi.fn(),
18
+ $: vi.fn(),
19
+ $$: vi.fn(),
20
+ getByRole: getByRoleMock,
21
+ getByLabel: getByLabelMock,
22
+ getByTestId: getByTestIdMock,
23
+ _mockElementHandle: mockElementHandle,
24
+ };
25
+ };
26
+
27
+ type MockPage = ReturnType<typeof createMockPage>;
28
+
29
+ // Import after creating mocks
30
+ import { SelectorEngine } from './selector-engine';
31
+
32
+ describe('SelectorEngine', () => {
33
+ let page: MockPage;
34
+ let engine: SelectorEngine;
35
+
36
+ beforeEach(() => {
37
+ page = createMockPage();
38
+ engine = new SelectorEngine(page as any);
39
+ });
40
+
41
+ describe('findElement', () => {
42
+ it('should return element when accessibility query succeeds', async () => {
43
+ const mockElement = { dispose: vi.fn() };
44
+ (page.getByRole as Mock).mockReturnValue({
45
+ elementHandle: vi.fn().mockResolvedValue(mockElement),
46
+ });
47
+
48
+ const result = await engine.findElement('messages', 'conversationList', { timeout: 1000 });
49
+
50
+ expect(result.element).toBe(mockElement);
51
+ expect(result.selector).toBeTruthy();
52
+ });
53
+
54
+ it('should return null when no selectors match', async () => {
55
+ // Mock all accessibility methods to return null
56
+ (page.getByRole as Mock).mockReturnValue({
57
+ elementHandle: vi.fn().mockRejectedValue(new Error('Not found')),
58
+ });
59
+ (page.getByLabel as Mock).mockReturnValue({
60
+ elementHandle: vi.fn().mockRejectedValue(new Error('Not found')),
61
+ });
62
+ (page.getByTestId as Mock).mockReturnValue({
63
+ elementHandle: vi.fn().mockRejectedValue(new Error('Not found')),
64
+ });
65
+
66
+ const result = await engine.findElement('messages', 'conversationList', { timeout: 1000 });
67
+
68
+ expect(result.element).toBeNull();
69
+ expect(result.selector).toBeNull();
70
+ expect(result.attempts.length).toBeGreaterThan(0);
71
+ });
72
+
73
+ it('should use visible state when requested', async () => {
74
+ const mockElement = { dispose: vi.fn() };
75
+ (page.getByRole as Mock).mockReturnValue({
76
+ elementHandle: vi.fn().mockResolvedValue(mockElement),
77
+ });
78
+
79
+ await engine.findElement('messages', 'conversationList', {
80
+ timeout: 1000,
81
+ visible: true,
82
+ });
83
+
84
+ // Verify getByRole was called (accessibility-first approach)
85
+ expect(page.getByRole).toHaveBeenCalled();
86
+ });
87
+ });
88
+
89
+ describe('hasElement', () => {
90
+ it('should return true when element exists', async () => {
91
+ const mockElement = { dispose: vi.fn() };
92
+ (page.getByRole as Mock).mockReturnValue({
93
+ elementHandle: vi.fn().mockResolvedValue(mockElement),
94
+ });
95
+
96
+ const result = await engine.hasElement('messages', 'conversationList');
97
+
98
+ expect(result).toBe(true);
99
+ });
100
+
101
+ it('should return false when element does not exist', async () => {
102
+ (page.getByRole as Mock).mockReturnValue({
103
+ elementHandle: vi.fn().mockRejectedValue(new Error('Not found')),
104
+ });
105
+ (page.getByLabel as Mock).mockReturnValue({
106
+ elementHandle: vi.fn().mockRejectedValue(new Error('Not found')),
107
+ });
108
+ (page.getByTestId as Mock).mockReturnValue({
109
+ elementHandle: vi.fn().mockRejectedValue(new Error('Not found')),
110
+ });
111
+
112
+ const result = await engine.hasElement('messages', 'conversationList');
113
+
114
+ expect(result).toBe(false);
115
+ });
116
+
117
+ it('should try multiple accessibility queries', async () => {
118
+ const mockElement = { dispose: vi.fn() };
119
+
120
+ // Mock getByRole to succeed
121
+ (page.getByRole as Mock).mockReturnValue({
122
+ elementHandle: vi.fn().mockResolvedValue(mockElement),
123
+ });
124
+
125
+ const result = await engine.hasElement('messages', 'conversationList');
126
+
127
+ expect(result).toBe(true);
128
+ expect(page.getByRole).toHaveBeenCalled();
129
+ });
130
+ });
131
+
132
+ describe('findAccessible', () => {
133
+ it('should find element by role and name', async () => {
134
+ const mockElement = { dispose: vi.fn() };
135
+ (page.waitForSelector as Mock).mockResolvedValue(mockElement);
136
+
137
+ const result = await engine.findAccessible({
138
+ role: 'button',
139
+ name: 'Submit',
140
+ });
141
+
142
+ expect(result).toBe(mockElement);
143
+ });
144
+
145
+ it('should find element by label', async () => {
146
+ const mockElement = { dispose: vi.fn() };
147
+ (page.waitForSelector as Mock).mockResolvedValue(mockElement);
148
+
149
+ const result = await engine.findAccessible({
150
+ label: 'Email address',
151
+ });
152
+
153
+ expect(result).toBe(mockElement);
154
+ });
155
+
156
+ it('should return null when no accessible element found', async () => {
157
+ (page.waitForSelector as Mock).mockRejectedValue(new Error('Not found'));
158
+
159
+ const result = await engine.findAccessible({
160
+ role: 'button',
161
+ name: 'NonExistent',
162
+ });
163
+
164
+ expect(result).toBeNull();
165
+ });
166
+ });
167
+ });