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,943 @@
1
+ # LinkedIn Profile Extraction Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add LinkedIn profile data extraction feature with CLI command support**Architecture:** Create `LinkedInProfile` class using existing `SelectorEngine` pattern, multi-layer selectors for and result types following existing conventions
6
+ **Tech Stack:** TypeScript, Playwright, Vitest, Commander.js
7
+
8
+ ---
9
+
10
+ ## Chunk 1: Foundation
11
+
12
+ ### Task 1: Add Types
13
+
14
+ **Files:**
15
+ - Modify: `src/types/index.ts`
16
+ - Test: `src/types/index.test.ts`
17
+
18
+ - [ ] **Step 1: Write failing test**
19
+
20
+ ```typescript
21
+ // src/types/index.test.ts
22
+ import { describe, it, expect } from 'vitest';
23
+ import type { ProfileData, ProfileExtractionOptions, ProfileExtractionResult } from '../types';
24
+
25
+ describe('Profile Types', () => {
26
+ it('should allow valid ProfileData', () => {
27
+ const data: ProfileData = {
28
+ full_name: 'John Doe',
29
+ headline: 'Engineer at Company',
30
+ location: 'San Francisco',
31
+ current_company: 'Company',
32
+ current_title: 'Engineer',
33
+ company_linkedin_url: 'https://linkedin.com/company/x',
34
+ years_experience: 5,
35
+ email: 'john@example.com',
36
+ phone: '+1234567890',
37
+ profile_url: 'https://www.linkedin.com/in/johndoe',
38
+ };
39
+ expect(data.full_name).toBe('John Doe');
40
+ });
41
+
42
+ it('should allow null optional fields', () => {
43
+ const data: ProfileData = {
44
+ full_name: 'Jane Doe',
45
+ headline: '',
46
+ location: '',
47
+ current_company: null,
48
+ current_title: null,
49
+ company_linkedin_url: null,
50
+ years_experience: null,
51
+ email: null,
52
+ phone: null,
53
+ profile_url: 'https://www.linkedin.com/in/janedoe',
54
+ };
55
+ expect(data.current_company).toBeNull();
56
+ });
57
+ });
58
+ ```
59
+
60
+ - [ ] **Step 2: Run test to verify it fails**
61
+
62
+ ```bash
63
+ npm test --grep "Profile Types"
64
+ ```
65
+
66
+ Expected: FAIL (module not found)
67
+
68
+ - [ ] **Step 3: Add types to src/types/index.ts**
69
+
70
+ ```typescript
71
+ // Append to src/types/index.ts (after existing types)
72
+
73
+ // ============================================================================
74
+ // Profile Extraction Types
75
+ // ============================================================================
76
+
77
+ /**
78
+ * Profile data extracted from a LinkedIn profile
79
+ */
80
+ export interface ProfileData {
81
+ // Top card fields
82
+ full_name: string;
83
+ headline: string;
84
+ location: string;
85
+
86
+ // Experience section
87
+ current_company: string | null;
88
+ current_title: string | null;
89
+ company_linkedin_url: string | null;
90
+
91
+ // Calculated
92
+ years_experience: number | null;
93
+
94
+ // Contact info (optional - requires clicking to reveal)
95
+ email: string | null;
96
+ phone: string | null;
97
+
98
+ // Reference
99
+ profile_url: string; // Canonical URL after LinkedIn redirects
100
+ }
101
+
102
+ /**
103
+ * Options for profile extraction
104
+ */
105
+ export interface ProfileExtractionOptions {
106
+ profileUrl: string;
107
+ includeContact?: boolean;
108
+ timeout?: number;
109
+ }
110
+
111
+ /**
112
+ * Result of profile extraction following existing pattern
113
+ */
114
+ export interface ProfileExtractionResult {
115
+ success: boolean;
116
+ message: string;
117
+ data?: ProfileData;
118
+ error?: string;
119
+ }
120
+ ```
121
+
122
+ - [ ] **Step 4: Run test to verify it passes**
123
+
124
+ ```bash
125
+ npm test --grep "Profile Types"
126
+ ```
127
+
128
+ Expected: PASS
129
+
130
+ - [ ] **Step 5: Commit**
131
+
132
+ ```bash
133
+ git add src/types/index.ts src/types/index.test.ts
134
+ git commit -m "feat: add ProfileData and related types
135
+
136
+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
137
+ ```
138
+
139
+ ---
140
+
141
+ ### Task 2: Add Profile Selectors
142
+
143
+ **Files:**
144
+ - Modify: `src/linkedin/selectors.ts`
145
+
146
+ - [ ] **Step 1: Add profile selectors to Add to `src/linkedin/selectors.ts` inside the `SELECTORS` object (after `connection`):
147
+
148
+ ```typescript
149
+ profile: {
150
+ // Top card
151
+ name: [
152
+ 'h1.text-heading-xlarge',
153
+ '[data-testid="top-card-profile-name"]',
154
+ '.pv-top-card .text-heading-xlarge',
155
+ 'section.artdeco-card h1',
156
+ ],
157
+ headline: [
158
+ '.text-body-medium',
159
+ '[data-testid="top-card-profile-headline"]',
160
+ '.pv-top-card .text-body-medium',
161
+ ],
162
+ location: [
163
+ '.text-body-small.inline.t-black--light',
164
+ '[data-testid="top-card-profile-location"]',
165
+ '.pv-top-card .text-body-small',
166
+ ],
167
+
168
+ // Experience section
169
+ experienceSection: [
170
+ '#experience',
171
+ 'section[id*="experience"]',
172
+ '[data-testid="experience-section"]',
173
+ ],
174
+ experienceList: [
175
+ '.pv-profile-section__list-item',
176
+ '[data-testid="experience-item"]',
177
+ 'li[class*="experience"]',
178
+ ],
179
+ experienceTitle: [
180
+ '.pv-entity__secondary-title',
181
+ '[data-testid="experience-title"]',
182
+ 'span[aria-hidden="true"]',
183
+ ],
184
+ experienceCompany: [
185
+ '.pv-entity__company-summary-info',
186
+ '[data-testid="experience-company"]',
187
+ 'a[href*="/company/"]',
188
+ ],
189
+ experienceDuration: [
190
+ '.pv-entity__date-range',
191
+ '[data-testid="experience-duration"]',
192
+ 'span[class*="date-range"]',
193
+ ],
194
+ companyLink: [
195
+ 'a[href*="/company/"]',
196
+ '[data-testid="company-link"]',
197
+ ],
198
+
199
+ // Contact info
200
+ contactInfoButton: [
201
+ 'a[href*="contact-info"]',
202
+ 'button[aria-label*="contact info" i]',
203
+ '[data-control-name="contact_see_more"]',
204
+ ],
205
+ contactInfoPanel: [
206
+ '.pv-contact-info',
207
+ '[data-testid="contact-info-panel"]',
208
+ '.artdeco-modal__content',
209
+ ],
210
+ contactInfoCloseButton: [
211
+ '.artdeco-modal__dismiss',
212
+ 'button[aria-label*="Dismiss"]',
213
+ 'button[aria-label*="Close"]',
214
+ '[data-testid="modal-close"]',
215
+ ],
216
+ email: [
217
+ 'a[href^="mailto:"]',
218
+ '[data-testid="contact-email"]',
219
+ '.pv-contact-info__contact-type[href*="mailto"]',
220
+ ],
221
+ phone: [
222
+ 'a[href^="tel:"]',
223
+ '[data-testid="contact-phone"]',
224
+ '.pv-contact-info__contact-type[href*="tel"]',
225
+ ],
226
+ } as const,
227
+ ```
228
+
229
+ - [ ] **Step 2: Verify build**
230
+
231
+ ```bash
232
+ npm run build
233
+ ```
234
+
235
+ Expected: PASS
236
+
237
+ - [ ] **Step 3: Commit**
238
+
239
+ ```bash
240
+ git add src/linkedin/selectors.ts
241
+ git commit -m "feat: add profile selectors
242
+
243
+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
244
+ ```
245
+
246
+ ---
247
+
248
+ ### Task 3: Add Environment Variable
249
+
250
+ **Files:**
251
+ - Modify: `.env.example`
252
+
253
+ - [ ] **Step 1: Add PAGE_AGENT_CDP_PORT**
254
+
255
+ ```bash
256
+ # Append to .env.example
257
+ echo "" >> .env.example
258
+ echo "# Page Agent CDP Port (default: 9222)" >> .env.example
259
+ echo "PAGE_AGENT_CDP_PORT=9222" >> .env.example
260
+ ```
261
+
262
+ - [ ] **Step 2: Commit**
263
+
264
+ ```bash
265
+ git add .env.example
266
+ git commit -m "docs: add PAGE_AGENT_CDP_PORT to env example
267
+
268
+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
269
+ ```
270
+
271
+ ---
272
+
273
+ ## Chunk 2: Core Implementation
274
+
275
+ ### Task 4: Implement LinkedInProfile Class
276
+
277
+ **Files:**
278
+ - Create: `src/linkedin/profile.ts`
279
+ - Create: `src/linkedin/profile.test.ts`
280
+
281
+ - [ ] **Step 1: Write failing test**
282
+
283
+ ```typescript
284
+ // src/linkedin/profile.test.ts
285
+ import { describe, it, expect } from 'vitest';
286
+ import {
287
+ LinkedInProfile,
288
+ validateProfileUrl,
289
+ parseDurationString,
290
+ calculateTotalExperience
291
+ } from './profile';
292
+
293
+ describe('LinkedInProfile', () => {
294
+ it('should be defined', () => {
295
+ expect(LinkedInProfile).toBeDefined();
296
+ });
297
+ });
298
+
299
+ describe('validateProfileUrl', () => {
300
+ it('should accept valid LinkedIn profile URLs', () => {
301
+ const validUrls = [
302
+ 'https://www.linkedin.com/in/johndoe/',
303
+ 'https://linkedin.com/in/jane-doe-123/',
304
+ 'http://www.linkedin.com/in/user',
305
+ ];
306
+ validUrls.forEach(url => {
307
+ expect(validateProfileUrl(url)).toBe(true);
308
+ });
309
+ });
310
+
311
+ it('should reject invalid URLs', () => {
312
+ const invalidUrls = [
313
+ 'https://www.google.com/',
314
+ 'https://linkedin.com/company/test',
315
+ 'not-a-url',
316
+ '',
317
+ ];
318
+ invalidUrls.forEach(url => {
319
+ expect(validateProfileUrl(url)).toBe(false);
320
+ });
321
+ });
322
+ });
323
+
324
+ describe('parseDurationString', () => {
325
+ it('should parse "Present" duration', () => {
326
+ const result = parseDurationString('Jan 2020 - Present (5 yrs 3 mos)');
327
+ expect(result).not.toBeNull();
328
+ expect(result!.years).toBe(5);
329
+ expect(result!.endDate).toBeNull();
330
+ });
331
+
332
+ it('should parse range duration', () => {
333
+ const result = parseDurationString('Mar 2019 - Dec 2021 (2 yr 10 mos)');
334
+ expect(result).not.toBeNull();
335
+ expect(result!.years).toBe(1); // Uses the10 from "(2 yr 10 mos)"
336
+ });
337
+
338
+ it('should parse year-only duration', () => {
339
+ const result = parseDurationString('2018 - 2022 (4 years)');
340
+ expect(result).not.toBeNull();
341
+ expect(result!.years).toBe(4);
342
+ });
343
+
344
+ it('should return null for invalid duration', () => {
345
+ expect(parseDurationString('invalid')).toBeNull();
346
+ expect(parseDurationString('')).toBeNull();
347
+ });
348
+ });
349
+
350
+ describe('calculateTotalExperience', () => {
351
+ it('should sum years from durations', () => {
352
+ const durations = [
353
+ { startDate: new Date(), endDate: null, years: 5 },
354
+ { startDate: new Date(), endDate: new Date(), years: 3 },
355
+ ];
356
+ expect(calculateTotalExperience(durations)).toBe(8);
357
+ });
358
+
359
+ it('should return 0 for empty array', () => {
360
+ expect(calculateTotalExperience([])).toBe(0);
361
+ });
362
+ });
363
+ ```
364
+
365
+ - [ ] **Step 2: Run test to verify it fails**
366
+
367
+ ```bash
368
+ npm test --grep "LinkedInProfile"
369
+ ```
370
+
371
+ Expected: FAIL (module not found)
372
+
373
+ - [ ] **Step 3: Write implementation**
374
+
375
+ ```typescript
376
+ // src/linkedin/profile.ts
377
+ import type { Page } from 'playwright';
378
+ import { SelectorEngine } from './selector-engine';
379
+ import { SELECTORS } from './selectors';
380
+ import type {
381
+ ProfileData,
382
+ ProfileExtractionOptions
383
+ ProfileExtractionResult
384
+ } from '../types';
385
+
386
+ // ============================================================================
387
+ // URL Validation
388
+ // ============================================================================
389
+
390
+ const LINKEDIN_PROFILE_URL_REGEX = /^https?:\/\/(?:www\.)?linkedin\.com\/in\/[^\/]+\/?$/;
391
+
392
+ export function validateProfileUrl(url: string): boolean {
393
+ return LINKEDIN_PROFILE_URL_REGEX.test(url);
394
+ }
395
+
396
+ // ============================================================================
397
+ // Duration Parsing
398
+ // ============================================================================
399
+
400
+ interface ParsedDuration {
401
+ startDate: Date;
402
+ endDate: Date | null;
403
+ years: number;
404
+ }
405
+
406
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
407
+
408
+ function parseMonthYear(text: string): Date {
409
+ const parts = text.trim().split(/\s+/);
410
+ const monthName = parts[0];
411
+ const year = parseInt(parts[1], 10);
412
+ const month = MONTHS.indexOf(monthName);
413
+ return new Date(year, month, 1);
414
+ }
415
+
416
+ export function parseDurationString(durationText: string): ParsedDuration | null {
417
+ if (!durationText || durationText.trim().length === 0) {
418
+ return null;
419
+ }
420
+
421
+ const text = durationText.trim();
422
+
423
+ // Pattern: "Jan 2020 - Present (5 yrs 3 mos)"
424
+ const presentPattern = /^(\w+\s+\d{4})\s*-\s*Present\s*\((\d+)\s*yrs?\s*(?:\d+\s*mos?)?\)$/i;
425
+ // Pattern: "Mar 2019 - Dec 2021 (2 yr 10 mos)"
426
+ const rangePattern = /^(\w+\s+\d{4})\s*-\s*(\w+\s+\d{4})\s*\((\d+)\s*(?:yrs?|years?)\s*(?:\d+\s*mos?)?\)$/i;
427
+ // Pattern: "2018 - 2022 (4 years)"
428
+ const yearOnlyPattern = /^(\d{4})\s*-\s*(\d{4})\s*\((\d+)\s*years?\)$/i;
429
+
430
+ let match = text.match(presentPattern);
431
+ if (match) {
432
+ const startDate = parseMonthYear(match[1]);
433
+ const years = parseInt(match[2], 10);
434
+ return { startDate, endDate: null, years };
435
+ }
436
+
437
+ match = text.match(rangePattern);
438
+ if (match) {
439
+ const startDate = parseMonthYear(match[1]);
440
+ const endDate = parseMonthYear(match[2]);
441
+ const years = parseInt(match[3], 10);
442
+ return { startDate, endDate, years };
443
+ }
444
+
445
+ match = text.match(yearOnlyPattern);
446
+ if (match) {
447
+ const startDate = new Date(parseInt(match[1], 10), 0, 1);
448
+ const endDate = new Date(parseInt(match[2], 10), 11, 31);
449
+ const years = parseInt(match[3], 10);
450
+ return { startDate, endDate, years };
451
+ }
452
+
453
+ return null;
454
+ }
455
+
456
+ export function calculateTotalExperience(durations: ParsedDuration[]): number {
457
+ return durations.reduce((total, d) => total + d.years, 0);
458
+ }
459
+
460
+ // ============================================================================
461
+ // LinkedInProfile Class
462
+ // ============================================================================
463
+
464
+ /**
465
+ * LinkedInProfile class for extracting structured data from LinkedIn profiles.
466
+ * Uses SelectorEngine with multi-layer fallbacks for resilience.
467
+ */
468
+ export class LinkedInProfile {
469
+ private page: Page;
470
+ private selectorEngine: SelectorEngine;
471
+
472
+ constructor(page: Page) {
473
+ this.page = page;
474
+ this.selectorEngine = new SelectorEngine(page);
475
+ }
476
+
477
+ /**
478
+ * Extract profile data from a LinkedIn profile URL.
479
+ */
480
+ async extract(options: ProfileExtractionOptions): Promise<ProfileExtractionResult> {
481
+ const timeout = options.timeout ?? 30000;
482
+
483
+ // Validate URL
484
+ if (!validateProfileUrl(options.profileUrl)) {
485
+ return {
486
+ success: false,
487
+ message: 'Invalid LinkedIn profile URL',
488
+ error: 'URL must match pattern: https://www.linkedin.com/in/{username}/',
489
+ };
490
+ }
491
+
492
+ try {
493
+ // Navigate to profile
494
+ await this.page.goto(options.profileUrl, {
495
+ waitUntil: 'domcontentloaded',
496
+ timeout,
497
+ });
498
+ await this.page.waitForTimeout(3000);
499
+
500
+ // Extract top card data
501
+ const fullName = await this.extractText('profile', 'name');
502
+ if (!fullName) {
503
+ return {
504
+ success: false,
505
+ message: 'Could not extract profile name',
506
+ error: 'Profile name not found on page',
507
+ };
508
+ }
509
+
510
+ const headline = await this.extractText('profile', 'headline');
511
+ const location = await this.extractText('profile', 'location');
512
+
513
+ // Extract experience data
514
+ const experienceData = await this.extractExperienceData();
515
+
516
+ // Extract contact info if requested
517
+ let email: string | null = null;
518
+ let phone: string | null = null;
519
+ if (options.includeContact) {
520
+ const contactInfo = await this.extractContactInfo();
521
+ email = contactInfo.email;
522
+ phone = contactInfo.phone;
523
+ }
524
+
525
+ const profileData: ProfileData = {
526
+ full_name: fullName,
527
+ headline: headline ?? '',
528
+ location: location ?? '',
529
+ current_company: experienceData.currentCompany,
530
+ current_title: experienceData.currentTitle,
531
+ company_linkedin_url: experienceData.companyUrl,
532
+ years_experience: experienceData.yearsExperience,
533
+ email,
534
+ phone,
535
+ profile_url: this.page.url(),
536
+ };
537
+
538
+ return {
539
+ success: true,
540
+ message: 'Profile extracted successfully',
541
+ data: profileData
542
+ };
543
+ } catch (error) {
544
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
545
+ return {
546
+ success: false,
547
+ message: errorMessage,
548
+ error: errorMessage
549
+ };
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Extract text using selector engine with fallbacks
555
+ */
556
+ private async extractText(
557
+ category: 'profile',
558
+ key: keyof typeof SELECTORS.profile
559
+ ): Promise<string | null> {
560
+ const result = await this.selectorEngine.findElement(category, key, { timeout: 5000 });
561
+ if (!result.element) {
562
+ return null;
563
+ }
564
+ const text = await result.element.textContent();
565
+ return text?.trim() || null;
566
+ }
567
+
568
+ /**
569
+ * Extract experience data from the profile
570
+ */
571
+ private async extractExperienceData(): Promise<{
572
+ currentCompany: string | null;
573
+ currentTitle: string | null;
574
+ companyUrl: string | null;
575
+ yearsExperience: number | null;
576
+ }> {
577
+ // Find experience section
578
+ const sectionResult = await this.selectorEngine.findElement('profile', 'experienceSection', { timeout: 5000 });
579
+ if (!sectionResult.element) {
580
+ return { currentCompany: null, currentTitle: null, companyUrl: null, yearsExperience: null };
581
+ }
582
+
583
+ // Get first experience item
584
+ const items = await this.page.$$(SELECTORS.profile.experienceList.join(', '));
585
+ if (items.length === 0) {
586
+ return { currentCompany: null, currentTitle: null, companyUrl: null, yearsExperience: null };
587
+ }
588
+
589
+ const firstItem = items[0];
590
+
591
+ // Extract title
592
+ let currentTitle: string | null = null;
593
+ for (const selector of SELECTORS.profile.experienceTitle) {
594
+ try {
595
+ const titleEl = await firstItem.$(selector);
596
+ if (titleEl) {
597
+ const text = await titleEl.textContent();
598
+ if (text?.trim()) {
599
+ currentTitle = text.trim();
600
+ break;
601
+ }
602
+ }
603
+ } catch {
604
+ // Try next selector
605
+ }
606
+ }
607
+
608
+ // Extract company and let currentCompany: string | null = null;
609
+ let companyUrl: string | null = null;
610
+ for (const selector of SELECTORS.profile.experienceCompany) {
611
+ try {
612
+ const companyEl = await firstItem.$(selector);
613
+ if (companyEl) {
614
+ const text = await companyEl.textContent();
615
+ if (text?.trim()) {
616
+ currentCompany = text.trim();
617
+ }
618
+ // Try to get company link
619
+ const linkEl = await companyEl.$('a[href*="/company/"]');
620
+ if (linkEl) {
621
+ companyUrl = await linkEl.getAttribute('href');
622
+ }
623
+ if (currentCompany) break;
624
+ }
625
+ } catch {
626
+ // Try next selector
627
+ }
628
+ }
629
+
630
+ // Extract duration for years calculation
631
+ let yearsExperience: number | null = null;
632
+ for (const selector of SELECTORS.profile.experienceDuration) {
633
+ try {
634
+ const durationEl = await firstItem.$(selector);
635
+ if (durationEl) {
636
+ const durationText = await durationEl.textContent();
637
+ const parsed = parseDurationString(durationText || '');
638
+ if (parsed) {
639
+ yearsExperience = parsed.years;
640
+ break;
641
+ }
642
+ }
643
+ } catch {
644
+ // Try next selector
645
+ }
646
+ }
647
+
648
+ return { currentCompany, currentTitle, companyUrl, yearsExperience };
649
+ }
650
+
651
+ /**
652
+ * Extract contact info by clicking the contact info button
653
+ */
654
+ private async extractContactInfo(): Promise<{ email: string | null; phone: string | null }> {
655
+ // Click contact info button
656
+ const buttonResult = await this.selectorEngine.findElement('profile', 'contactInfoButton', { timeout: 5000 });
657
+ if (!buttonResult.element) {
658
+ return { email: null, phone: null };
659
+ }
660
+
661
+ await buttonResult.element.click();
662
+ await this.page.waitForTimeout(2000);
663
+
664
+ // Extract email and phone
665
+ const email = await this.extractText('profile', 'email');
666
+ const phone = await this.extractText('profile', 'phone');
667
+
668
+ // Close modal
669
+ const closeResult = await this.selectorEngine.findElement('profile', 'contactInfoCloseButton', { timeout: 2000 });
670
+ if (closeResult.element) {
671
+ await closeResult.element.click();
672
+ } else {
673
+ await this.page.keyboard.press('Escape');
674
+ }
675
+ await this.page.waitForTimeout(1000);
676
+
677
+ return { email, phone };
678
+ }
679
+ }
680
+ ```
681
+
682
+ - [ ] **Step 4: Run test to verify it passes**
683
+
684
+ ```bash
685
+ npm test --grep "LinkedInProfile"
686
+ ```
687
+
688
+ Expected: PASS
689
+
690
+ - [ ] **Step 5: Commit**
691
+
692
+ ```bash
693
+ git add src/linkedin/profile.ts src/linkedin/profile.test.ts
694
+ git commit -m "feat: implement LinkedInProfile class
695
+
696
+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
697
+ ```
698
+
699
+ ---
700
+
701
+ ## Chunk 3: CLI Commands
702
+
703
+ ### Task 5: Add CLI Commands
704
+
705
+ **Files:**
706
+ - Create: `src/cli/profile.ts`
707
+ - Create: `src/cli/profile.test.ts`
708
+ - Modify: `src/cli/index.ts`
709
+
710
+ - [ ] **Step 1: Write failing test**
711
+
712
+ ```typescript
713
+ // src/cli/profile.test.ts
714
+ import { describe, it, expect } from 'vitest';
715
+ import { Command } from 'commander';
716
+ import { registerProfileCommands } from './profile';
717
+
718
+ describe('registerProfileCommands', () => {
719
+ it('should register profile command', () => {
720
+ const program = new Command();
721
+ registerProfileCommands(program);
722
+
723
+ const commands = program.commands;
724
+ expect(commands.some(c => c.name() === 'profile')).toBe(true);
725
+ });
726
+ });
727
+ ```
728
+
729
+ - [ ] **Step 2: Run test to verify it fails**
730
+
731
+ ```bash
732
+ npm test --grep "registerProfileCommands"
733
+ ```
734
+
735
+ Expected: FAIL (module not found)
736
+
737
+ - [ ] **Step 3: Write CLI implementation**
738
+
739
+ ```typescript
740
+ // src/cli/profile.ts
741
+ import { Command } from 'commander';
742
+ import { BrowserController } from '../core/browser';
743
+ import { LinkedInProfile } from '../linkedin/profile';
744
+ import { getAuditLogger } from '../core/audit';
745
+ import type { ProfileData } from '../types';
746
+
747
+ const CDP_PORT = parseInt(process.env.PAGE_AGENT_CDP_PORT || '9222', 10);
748
+
749
+ export function registerProfileCommands(program: Command): void {
750
+ const profile = program.command('profile').description('LinkedIn profile commands');
751
+
752
+ profile
753
+ .command('get <url>')
754
+ .description('Extract profile data from a LinkedIn URL')
755
+ .option('--include-contact', 'Extract contact info (email, phone)')
756
+ .option('--json', 'Output as JSON')
757
+ .option('--no-cdp', 'Do not connect to existing browser via CDP')
758
+ .option('--cdp-port <port>', 'CDP port number', String(CDP_PORT))
759
+ .action(async (url: string, options) => {
760
+ const browser = new BrowserController();
761
+
762
+ try {
763
+ // Initialize browser
764
+ await browser.init({
765
+ connectToCDP: !options.noCdp,
766
+ cdpPort: parseInt(options.cdpPort, 10),
767
+ headless: false,
768
+ });
769
+
770
+ const page = browser.getPage();
771
+ if (!page) {
772
+ console.error('Failed to get page from browser');
773
+ process.exit(1);
774
+ }
775
+
776
+ const extractor = new LinkedInProfile(page);
777
+
778
+ // Extract profile
779
+ const result = await extractor.extract({
780
+ profileUrl: url,
781
+ includeContact: options.includeContact,
782
+ });
783
+
784
+ // Log audit
785
+ await getAuditLogger().log({
786
+ action: 'profile_extract',
787
+ details: {
788
+ profileUrl: url,
789
+ includeContact: options.includeContact ?? false,
790
+ success: result.success,
791
+ extractedFields: result.data
792
+ ? (Object.keys(result.data) as (keyof ProfileData)[]).filter(
793
+ (k) => result.data![k] !== null
794
+ )
795
+ : [],
796
+ },
797
+ success: result.success,
798
+ error: result.error,
799
+ });
800
+
801
+ if (result.success && result.data) {
802
+ if (options.json) {
803
+ console.log(JSON.stringify(result.data, null, 2));
804
+ } else {
805
+ printProfileOutput(result.data, options.includeContact);
806
+ }
807
+ } else {
808
+ console.error(`Error: ${result.error}`);
809
+ process.exit(1);
810
+ }
811
+ } finally {
812
+ await browser.close();
813
+ }
814
+ });
815
+ }
816
+
817
+ /**
818
+ * Print profile data in human-readable format
819
+ */
820
+ function printProfileOutput(data: ProfileData, includeContact?: boolean): void {
821
+ console.log(`\nProfile: ${data.full_name}`);
822
+ console.log('━'.repeat(50));
823
+ console.log(`Headline: ${data.headline || 'N/A'}`);
824
+ console.log(`Location: ${data.location || 'N/A'}`);
825
+ console.log(`Company: ${data.current_company ?? 'N/A'}`);
826
+ console.log(`Title: ${data.current_title ?? 'N/A'}`);
827
+ console.log(`Company URL: ${data.company_linkedin_url ?? 'N/A'}`);
828
+ console.log(`Experience: ${data.years_experience !== null ? `${data.years_experience} years` : 'N/A'}`);
829
+
830
+ if (includeContact) {
831
+ console.log(`Email: ${data.email ?? 'Not available'}`);
832
+ console.log(`Phone: ${data.phone ?? 'Not available'}`);
833
+ } else {
834
+ console.log(`Email: (use --include-contact to reveal)`);
835
+ console.log(`Phone: (use --include-contact to reveal)`);
836
+ }
837
+ }
838
+ ```
839
+
840
+ - [ ] **Step 4: Update src/cli/index.ts exports**
841
+
842
+ ```typescript
843
+ // Add to src/cli/index.ts
844
+ export { registerProfileCommands } from './profile';
845
+ ```
846
+
847
+ - [ ] **Step 5: Run test to verify it passes**
848
+
849
+ ```bash
850
+ npm test --grep "registerProfileCommands"
851
+ ```
852
+
853
+ Expected: PASS
854
+
855
+ - [ ] **Step 6: Run build**
856
+
857
+ ```bash
858
+ npm run build
859
+ ```
860
+
861
+ Expected: PASS
862
+
863
+ - [ ] **Step 7: Commit**
864
+
865
+ ```bash
866
+ git add src/cli/profile.ts src/cli/profile.test.ts src/cli/index.ts
867
+ git commit -m "feat: add profile CLI commands
868
+
869
+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
870
+ ```
871
+
872
+ ---
873
+
874
+ ## Chunk 4: Finalization
875
+
876
+ ### Task 6: Run Full Test Suite
877
+
878
+ - [ ] **Step 1: Run all tests**
879
+
880
+ ```bash
881
+ npm test
882
+ ```
883
+
884
+ Expected: All tests pass
885
+
886
+ - [ ] **Step 2: Run type check**
887
+
888
+ ```bash
889
+ npm run typecheck
890
+ ```
891
+
892
+ Expected: No errors
893
+
894
+ - [ ] **Step 3: Run lint**
895
+
896
+ ```bash
897
+ npm run lint
898
+ ```
899
+
900
+ Expected: No errors (fix if any)
901
+
902
+ - [ ] **Step 4: Run build**
903
+
904
+ ```bash
905
+ npm run build
906
+ ```
907
+
908
+ Expected: Success
909
+
910
+ - [ ] **Step 5: Commit any fixes if needed**
911
+
912
+ ---
913
+
914
+ ### Task 7: Final Review and Commit
915
+
916
+ - [ ] **Step 1: Review all changes**
917
+
918
+ ```bash
919
+ git status
920
+ git log --oneline -10
921
+ ```
922
+
923
+ - [ ] **Step 2: Create summary commit if needed**
924
+
925
+ ---
926
+
927
+ ## Summary
928
+
929
+ **Files Created:**
930
+ - `src/linkedin/profile.ts` - LinkedInProfile class with URL validation, duration parsing
931
+ - `src/linkedin/profile.test.ts` - Unit tests
932
+ - `src/cli/profile.ts` - CLI commands
933
+ - `src/cli/profile.test.ts` - CLI tests
934
+ - `src/types/index.test.ts` - Type tests
935
+
936
+ **Files Modified:**
937
+ - `src/types/index.ts` - Added ProfileData, ProfileExtractionOptions, ProfileExtractionResult types
938
+ - `src/linkedin/selectors.ts` - Added profile selectors
939
+ - `src/cli/index.ts` - Exported registerProfileCommands
940
+ - `.env.example` - Added PAGE_AGENT_CDP_PORT
941
+
942
+ **Total Tasks: 7
943
+ **Estimated Time: 1-2 hours (with tests)