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,409 @@
1
+ # LinkedIn Profile Extraction Feature Design
2
+
3
+ **Date:** 2026-03-15
4
+ **Status:** Draft
5
+ **Author:** Claude
6
+
7
+ ## Overview
8
+
9
+ Add a feature to extract structured profile data from LinkedIn user profiles. This enables use cases like lead qualification, contact enrichment, and CRM integration.
10
+
11
+ ## Requirements
12
+
13
+ ### Input
14
+ - LinkedIn profile URL (e.g., `https://www.linkedin.com/in/patrick-fabre-b988ab10a/`)
15
+ - Optional: `--include-contact` flag to extract email/phone
16
+
17
+ ### Output Fields
18
+
19
+ | Field | Source | Use Case |
20
+ |-------|--------|----------|
21
+ | `full_name` | Top card | Contact |
22
+ | `headline` | Top card | Current role |
23
+ | `location` | Top card | Geography |
24
+ | `current_company` | Experience section | Employer |
25
+ | `current_title` | Experience section | Job title |
26
+ | `years_experience` | Calculated from experience | Seniority |
27
+ | `company_linkedin_url` | Experience section | Link to company |
28
+ | `email` | Contact info panel | Direct contact |
29
+ | `phone` | Contact info panel | Direct contact |
30
+
31
+ ### Constraints
32
+ - Requires authenticated LinkedIn session (connect via CDP to logged-in browser)
33
+ - Must handle LinkedIn's dynamic DOM with multi-layer selector fallbacks
34
+ - Contact info requires clicking to reveal (optional)
35
+
36
+ ## Architecture
37
+
38
+ ```
39
+ CLI (profile get <url>)
40
+
41
+
42
+ ┌─────────────────────────┐
43
+ │ src/cli/profile.ts │
44
+ │ registerProfileCommands│
45
+ └─────────────────────────┘
46
+
47
+
48
+ ┌─────────────────────────┐
49
+ │ src/linkedin/profile.ts │
50
+ │ LinkedInProfile │
51
+ │ - extract(url, opts) │
52
+ │ - uses SelectorEngine │
53
+ └─────────────────────────┘
54
+
55
+
56
+ ┌─────────────────────────┐
57
+ │ src/types/index.ts │
58
+ │ ProfileData, │
59
+ │ ProfileExtractionResult │
60
+ └─────────────────────────┘
61
+ ```
62
+
63
+ **Naming Convention**: Class is named `LinkedInProfile` (not `LinkedInProfileScraper`) to match existing patterns: `LinkedInConnector`, `LinkedInMessages`, `LinkedInAuth`.
64
+
65
+ ## Data Model
66
+
67
+ ```typescript
68
+ // src/types/index.ts
69
+
70
+ export interface ProfileData {
71
+ // Top card fields
72
+ full_name: string;
73
+ headline: string;
74
+ location: string;
75
+
76
+ // Experience section
77
+ current_company: string | null;
78
+ current_title: string | null;
79
+ company_linkedin_url: string | null;
80
+
81
+ // Calculated
82
+ years_experience: number | null;
83
+
84
+ // Contact info (optional - requires clicking to reveal)
85
+ email: string | null;
86
+ phone: string | null;
87
+
88
+ // Reference
89
+ profile_url: string; // Canonical URL after LinkedIn redirects
90
+ }
91
+
92
+ export interface ProfileExtractionOptions {
93
+ profileUrl: string;
94
+ includeContact?: boolean;
95
+ timeout?: number;
96
+ }
97
+
98
+ // Result type following existing pattern (ConnectionResult, MessageResult)
99
+ export interface ProfileExtractionResult {
100
+ success: boolean;
101
+ message: string;
102
+ data?: ProfileData;
103
+ error?: string;
104
+ }
105
+ ```
106
+
107
+ ## URL Validation
108
+
109
+ ```typescript
110
+ // Regex pattern for LinkedIn profile URLs
111
+ const LINKEDIN_PROFILE_URL_REGEX = /^https?:\/\/(?:www\.)?linkedin\.com\/in\/[^\/]+\/?$/;
112
+
113
+ function validateProfileUrl(url: string): boolean {
114
+ return LINKEDIN_PROFILE_URL_REGEX.test(url);
115
+ }
116
+ ```
117
+
118
+ ## CLI Interface
119
+
120
+ ```bash
121
+ # Basic usage (connect to CDP on default port)
122
+ linkedin-cli profile get https://www.linkedin.com/in/patrick-fabre-b988ab10a/
123
+
124
+ # With contact info extraction
125
+ linkedin-cli profile get https://www.linkedin.com/in/patrick-fabre-b988ab10a/ --include-contact
126
+
127
+ # JSON output
128
+ linkedin-cli profile get https://www.linkedin.com/in/patrick-fabre-b988ab10a/ --json
129
+
130
+ # Custom CDP port
131
+ linkedin-cli profile get https://www.linkedin.com/in/patrick-fabre-b988ab10a/ --cdp-port 9223
132
+ ```
133
+
134
+ ### Output Format (default)
135
+
136
+ ```
137
+ Profile: Patrick Fabre
138
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
139
+ Headline: Engineering Manager at Company
140
+ Location: San Francisco Bay Area
141
+ Company: Company Name
142
+ Title: Engineering Manager
143
+ Company URL: linkedin.com/company/...
144
+ Experience: 12 years
145
+ Email: (use --include-contact to reveal)
146
+ Phone: (use --include-contact to reveal)
147
+ ```
148
+
149
+ ### Output Format (--json)
150
+
151
+ ```json
152
+ {
153
+ "full_name": "Patrick Fabre",
154
+ "headline": "Engineering Manager at Company",
155
+ "location": "San Francisco Bay Area",
156
+ "current_company": "Company Name",
157
+ "current_title": "Engineering Manager",
158
+ "company_linkedin_url": "https://www.linkedin.com/company/...",
159
+ "years_experience": 12,
160
+ "email": null,
161
+ "phone": null
162
+ }
163
+ ```
164
+
165
+ ## Selectors
166
+
167
+ Add to `src/linkedin/selectors.ts`:
168
+
169
+ ```typescript
170
+ profile: {
171
+ // Top card
172
+ name: [
173
+ 'h1.text-heading-xlarge',
174
+ '[data-testid="top-card-profile-name"]',
175
+ '.pv-top-card .text-heading-xlarge',
176
+ 'section.artdeco-card h1',
177
+ ],
178
+ headline: [
179
+ '.text-body-medium',
180
+ '[data-testid="top-card-profile-headline"]',
181
+ '.pv-top-card .text-body-medium',
182
+ ],
183
+ location: [
184
+ '.text-body-small.inline.t-black--light',
185
+ '[data-testid="top-card-profile-location"]',
186
+ '.pv-top-card .text-body-small',
187
+ ],
188
+
189
+ // Experience section
190
+ experienceSection: [
191
+ '#experience',
192
+ 'section[id*="experience"]',
193
+ '[data-testid="experience-section"]',
194
+ ],
195
+ experienceList: [
196
+ '.pv-profile-section__list-item',
197
+ '[data-testid="experience-item"]',
198
+ 'li[class*="experience"]',
199
+ ],
200
+ experienceTitle: [
201
+ '.pv-entity__secondary-title',
202
+ '[data-testid="experience-title"]',
203
+ 'span[aria-hidden="true"]',
204
+ ],
205
+ experienceCompany: [
206
+ '.pv-entity__company-summary-info',
207
+ '[data-testid="experience-company"]',
208
+ 'a[href*="/company/"]',
209
+ ],
210
+ experienceDuration: [
211
+ '.pv-entity__date-range',
212
+ '[data-testid="experience-duration"]',
213
+ 'span[class*="date-range"]',
214
+ ],
215
+ companyLink: [
216
+ 'a[href*="/company/"]',
217
+ '[data-testid="company-link"]',
218
+ ],
219
+
220
+ // Contact info
221
+ contactInfoButton: [
222
+ 'a[href*="contact-info"]',
223
+ 'button[aria-label*="contact info" i]',
224
+ '[data-control-name="contact_see_more"]',
225
+ ],
226
+ contactInfoPanel: [
227
+ '.pv-contact-info',
228
+ '[data-testid="contact-info-panel"]',
229
+ '.artdeco-modal__content',
230
+ ],
231
+ contactInfoCloseButton: [
232
+ '.artdeco-modal__dismiss',
233
+ 'button[aria-label*="Dismiss"]',
234
+ 'button[aria-label*="Close"]',
235
+ '[data-testid="modal-close"]',
236
+ ],
237
+ email: [
238
+ 'a[href^="mailto:"]',
239
+ '[data-testid="contact-email"]',
240
+ '.pv-contact-info__contact-type[href*="mailto"]',
241
+ ],
242
+ phone: [
243
+ 'a[href^="tel:"]',
244
+ '[data-testid="contact-phone"]',
245
+ '.pv-contact-info__contact-type[href*="tel"]',
246
+ ],
247
+ },
248
+ ```
249
+
250
+ ## Experience Duration Parsing Algorithm
251
+
252
+ LinkedIn displays experience durations in formats like:
253
+ - `"Jan 2020 - Present (5 yrs 3 mos)"`
254
+ - `"2018 - 2022 (4 years)"`
255
+ - `"Mar 2019 - Dec 2021 (2 yrs 10 mos)"`
256
+
257
+ ```typescript
258
+ interface ParsedDuration {
259
+ startDate: Date;
260
+ endDate: Date | null; // null for "Present"
261
+ years: number;
262
+ }
263
+
264
+ function parseDurationString(durationText: string): ParsedDuration | null {
265
+ // Pattern: "Jan 2020 - Present (5 yrs 3 mos)" or "2018 - 2022 (4 years)"
266
+ const presentPattern = /^(\w+\s+\d{4})\s*-\s*Present\s*\((\d+)\s*yrs?\s*(?:\d+\s*mos?)?\)$/i;
267
+ const rangePattern = /^(\w+\s+\d{4})\s*-\s*(\w+\s+\d{4})\s*\((\d+)\s*years?\)$/i;
268
+ const yearOnlyPattern = /^(\d{4})\s*-\s*(\d{4})\s*\((\d+)\s*years?\)$/i;
269
+
270
+ // Try each pattern...
271
+ let match = durationText.match(presentPattern);
272
+ if (match) {
273
+ const startDate = parseMonthYear(match[1]);
274
+ const years = parseInt(match[2]);
275
+ return { startDate, endDate: null, years };
276
+ }
277
+
278
+ match = durationText.match(rangePattern);
279
+ if (match) {
280
+ const startDate = parseMonthYear(match[1]);
281
+ const endDate = parseMonthYear(match[2]);
282
+ const years = parseInt(match[3]);
283
+ return { startDate, endDate, years };
284
+ }
285
+
286
+ match = durationText.match(yearOnlyPattern);
287
+ if (match) {
288
+ const startDate = new Date(parseInt(match[1]), 0, 1);
289
+ const endDate = new Date(parseInt(match[2]), 11, 31);
290
+ const years = parseInt(match[3]);
291
+ return { startDate, endDate, years };
292
+ }
293
+
294
+ return null;
295
+ }
296
+
297
+ function calculateTotalExperience(durations: ParsedDuration[]): number {
298
+ // Sum years from all non-overlapping positions
299
+ // For simplicity, sum the "years" values (LinkedIn already calculates this)
300
+ return durations.reduce((total, d) => total + d.years, 0);
301
+ }
302
+ ```
303
+
304
+ **Note**: We only extract visible experience items. If "See all X experiences" button exists, we do NOT click it (avoids additional navigation delay). The `years_experience` is calculated from visible entries only.
305
+
306
+ ## Contact Info Modal Handling
307
+
308
+ When `--include-contact` flag is used:
309
+
310
+ 1. **Open modal**: Click contact info button
311
+ 2. **Wait for panel**: Wait for modal/panel to appear (2000ms)
312
+ 3. **Extract data**: Get email and phone values
313
+ 4. **Close modal**: Click dismiss button or press Escape
314
+ 5. **Verify closed**: Wait for modal to disappear (1000ms)
315
+
316
+ ```typescript
317
+ async extractContactInfo(page: Page): Promise<{ email: string | null; phone: string | null }> {
318
+ // Click contact info button
319
+ const contactButton = await this.findElement('profile', 'contactInfoButton');
320
+ if (!contactButton) {
321
+ return { email: null, phone: null };
322
+ }
323
+
324
+ await contactButton.click();
325
+ await page.waitForTimeout(2000);
326
+
327
+ // Extract email and phone
328
+ const email = await this.extractText('profile', 'email');
329
+ const phone = await this.extractText('profile', 'phone');
330
+
331
+ // Close modal
332
+ const closeButton = await this.findElement('profile', 'contactInfoCloseButton');
333
+ if (closeButton) {
334
+ await closeButton.click();
335
+ } else {
336
+ await page.keyboard.press('Escape');
337
+ }
338
+ await page.waitForTimeout(1000);
339
+
340
+ return { email, phone };
341
+ }
342
+ ```
343
+
344
+ ## Files to Create/Modify
345
+
346
+ | File | Action | Description |
347
+ |------|--------|-------------|
348
+ | `src/linkedin/profile.ts` | Create | `LinkedInProfile` class using `SelectorEngine` |
349
+ | `src/cli/profile.ts` | Create | CLI command registration |
350
+ | `src/linkedin/selectors.ts` | Modify | Add `profile` selector group |
351
+ | `src/types/index.ts` | Modify | Add `ProfileData`, `ProfileExtractionOptions`, `ProfileExtractionResult` types |
352
+ | `src/cli/index.ts` | Modify | Export `registerProfileCommands` |
353
+ | `.env.example` | Modify | Add `PAGE_AGENT_CDP_PORT` |
354
+
355
+ ## Environment Variables
356
+
357
+ ```bash
358
+ # .env.example
359
+ PAGE_AGENT_CDP_PORT=9222
360
+ ```
361
+
362
+ Default CDP port is `9222`. Override via `PAGE_AGENT_CDP_PORT` environment variable.
363
+
364
+ ## Error Handling
365
+
366
+ - **Navigation timeout**: Return error with message "Profile page not found or timeout"
367
+ - **Missing required fields**: Return error if `full_name` cannot be extracted
368
+ - **Contact info unavailable**: Return `null` for email/phone if not found or not accessible
369
+ - **Invalid URL**: Validate URL format before navigation
370
+
371
+ ## Testing Strategy
372
+
373
+ 1. **Unit tests**: Selector extraction logic with fixture HTML
374
+ 2. **Integration tests**: Mock Playwright page responses
375
+ 3. **E2E tests**: Test against real LinkedIn profile (requires logged-in browser)
376
+
377
+ ## Security Considerations
378
+
379
+ - Profile data may contain PII - handle according to privacy requirements
380
+ - Rate limiting: Respect LinkedIn's rate limits (reuse existing rate limiter from `src/utils/rate-limiter.ts`)
381
+ - Session security: Use existing encrypted session storage
382
+
383
+ ## Audit Logging
384
+
385
+ All profile extraction operations should be logged using the existing audit logger:
386
+
387
+ ```typescript
388
+ import { getAuditLogger } from '../core/audit';
389
+
390
+ // Log profile extraction attempt
391
+ await getAuditLogger().log({
392
+ action: 'profile_extract',
393
+ details: {
394
+ profileUrl: options.profileUrl,
395
+ includeContact: options.includeContact ?? false,
396
+ success: result.success,
397
+ extractedFields: Object.keys(result.data || {}).filter(
398
+ k => result.data?.[k as keyof ProfileData] !== null
399
+ ),
400
+ },
401
+ success: result.success,
402
+ error: result.error,
403
+ });
404
+ ```
405
+
406
+ **Audit events to log:**
407
+ - `profile_extract` - Profile extraction attempt (success or failure)
408
+ - Include which fields were successfully extracted
409
+ - Include error message on failure
@@ -0,0 +1,58 @@
1
+ import eslint from '@eslint/js';
2
+ import tseslint from '@typescript-eslint/eslint-plugin';
3
+ import parser from '@typescript-eslint/parser';
4
+ import prettierConfig from 'eslint-config-prettier';
5
+
6
+ export default [
7
+ eslint.configs.recommended,
8
+ {
9
+ files: ['**/*.ts'],
10
+ languageOptions: {
11
+ ecmaVersion: 2022,
12
+ sourceType: 'module',
13
+ parser: parser,
14
+ parserOptions: {
15
+ ecmaVersion: 'latest',
16
+ sourceType: 'module',
17
+ },
18
+ globals: {
19
+ console: 'readonly',
20
+ process: 'readonly',
21
+ Buffer: 'readonly',
22
+ __dirname: 'readonly',
23
+ __filename: 'readonly',
24
+ exports: 'readonly',
25
+ module: 'readonly',
26
+ require: 'readonly',
27
+ setTimeout: 'readonly',
28
+ clearTimeout: 'readonly',
29
+ AbortSignal: 'readonly',
30
+ document: 'readonly',
31
+ Element: 'readonly',
32
+ HTMLElement: 'readonly',
33
+ window: 'readonly',
34
+ navigator: 'readonly',
35
+ localStorage: 'readonly',
36
+ Notification: 'readonly',
37
+ fetch: 'readonly',
38
+ },
39
+ },
40
+ plugins: {
41
+ '@typescript-eslint': tseslint,
42
+ },
43
+ rules: {
44
+ ...tseslint.configs.recommended.rules,
45
+ '@typescript-eslint/no-explicit-any': 'warn',
46
+ '@typescript-eslint/no-unused-vars': ['error', {
47
+ argsIgnorePattern: '^_',
48
+ varsIgnorePattern: '^_',
49
+ }],
50
+ 'no-useless-escape': 'off',
51
+ '@typescript-eslint/no-var-requires': 'off',
52
+ },
53
+ },
54
+ prettierConfig,
55
+ {
56
+ ignores: ['node_modules/', 'dist/', 'coverage/', '*.js'],
57
+ },
58
+ ];
package/go.mod ADDED
@@ -0,0 +1,9 @@
1
+ module github.com/thaddeus-git/linkedin-cli
2
+
3
+ go 1.21
4
+
5
+ require (
6
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
7
+ github.com/spf13/cobra v1.10.2 // indirect
8
+ github.com/spf13/pflag v1.0.9 // indirect
9
+ )
package/go.sum ADDED
@@ -0,0 +1,10 @@
1
+ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2
+ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3
+ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
4
+ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
5
+ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
6
+ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
7
+ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
8
+ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
9
+ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
10
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=