linkedin-automation-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +12 -0
- package/.github/workflows/ci.yml +66 -0
- package/.github/workflows/publish.yml +48 -0
- package/.husky/pre-commit +6 -0
- package/.prettierignore +4 -0
- package/.prettierrc +10 -0
- package/AGENTS.md +294 -0
- package/CHANGELOG.md +40 -0
- package/GIT_RELEASE.md +167 -0
- package/LICENSE +21 -0
- package/Makefile +30 -0
- package/NPM_PUBLISHING.md +230 -0
- package/PYEOF +0 -0
- package/README.md +295 -0
- package/TESTING-GUIDE.md +151 -0
- package/cmd/linkedin/main.go +9 -0
- package/dist/agent/action-executor.d.ts +81 -0
- package/dist/agent/action-executor.d.ts.map +1 -0
- package/dist/agent/action-executor.js +170 -0
- package/dist/agent/action-executor.js.map +1 -0
- package/dist/agent/action-executor.test.d.ts +2 -0
- package/dist/agent/action-executor.test.d.ts.map +1 -0
- package/dist/agent/action-executor.test.js +366 -0
- package/dist/agent/action-executor.test.js.map +1 -0
- package/dist/agent/claude-client.d.ts +74 -0
- package/dist/agent/claude-client.d.ts.map +1 -0
- package/dist/agent/claude-client.js +314 -0
- package/dist/agent/claude-client.js.map +1 -0
- package/dist/agent/claude-client.test.d.ts +2 -0
- package/dist/agent/claude-client.test.d.ts.map +1 -0
- package/dist/agent/claude-client.test.js +590 -0
- package/dist/agent/claude-client.test.js.map +1 -0
- package/dist/agent/dom-extractor.d.ts +50 -0
- package/dist/agent/dom-extractor.d.ts.map +1 -0
- package/dist/agent/dom-extractor.js +374 -0
- package/dist/agent/dom-extractor.js.map +1 -0
- package/dist/agent/dom-extractor.test.d.ts +7 -0
- package/dist/agent/dom-extractor.test.d.ts.map +1 -0
- package/dist/agent/dom-extractor.test.js +504 -0
- package/dist/agent/dom-extractor.test.js.map +1 -0
- package/dist/agent/extension-client.d.ts +75 -0
- package/dist/agent/extension-client.d.ts.map +1 -0
- package/dist/agent/extension-client.js +245 -0
- package/dist/agent/extension-client.js.map +1 -0
- package/dist/agent/index.d.ts +8 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +16 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/agent/page-agent.d.ts +76 -0
- package/dist/agent/page-agent.d.ts.map +1 -0
- package/dist/agent/page-agent.js +236 -0
- package/dist/agent/page-agent.js.map +1 -0
- package/dist/agent/types.d.ts +236 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/types.js +37 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/cli/agent-commands.d.ts +3 -0
- package/dist/cli/agent-commands.d.ts.map +1 -0
- package/dist/cli/agent-commands.js +250 -0
- package/dist/cli/agent-commands.js.map +1 -0
- package/dist/cli/auth.d.ts +3 -0
- package/dist/cli/auth.d.ts.map +1 -0
- package/dist/cli/auth.js +288 -0
- package/dist/cli/auth.js.map +1 -0
- package/dist/cli/company.d.ts +3 -0
- package/dist/cli/company.d.ts.map +1 -0
- package/dist/cli/company.js +55 -0
- package/dist/cli/company.js.map +1 -0
- package/dist/cli/connection.d.ts +3 -0
- package/dist/cli/connection.d.ts.map +1 -0
- package/dist/cli/connection.js +79 -0
- package/dist/cli/connection.js.map +1 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +17 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/messages.d.ts +3 -0
- package/dist/cli/messages.d.ts.map +1 -0
- package/dist/cli/messages.js +268 -0
- package/dist/cli/messages.js.map +1 -0
- package/dist/cli/profile.d.ts +3 -0
- package/dist/cli/profile.d.ts.map +1 -0
- package/dist/cli/profile.js +81 -0
- package/dist/cli/profile.js.map +1 -0
- package/dist/cli/profile.test.d.ts +2 -0
- package/dist/cli/profile.test.d.ts.map +1 -0
- package/dist/cli/profile.test.js +15 -0
- package/dist/cli/profile.test.js.map +1 -0
- package/dist/cli/reply.d.ts +3 -0
- package/dist/cli/reply.d.ts.map +1 -0
- package/dist/cli/reply.js +129 -0
- package/dist/cli/reply.js.map +1 -0
- package/dist/core/audit.d.ts +17 -0
- package/dist/core/audit.d.ts.map +1 -0
- package/dist/core/audit.js +121 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/audit.test.d.ts +2 -0
- package/dist/core/audit.test.d.ts.map +1 -0
- package/dist/core/audit.test.js +142 -0
- package/dist/core/audit.test.js.map +1 -0
- package/dist/core/browser-cookies.d.ts +19 -0
- package/dist/core/browser-cookies.d.ts.map +1 -0
- package/dist/core/browser-cookies.js +181 -0
- package/dist/core/browser-cookies.js.map +1 -0
- package/dist/core/browser.d.ts +50 -0
- package/dist/core/browser.d.ts.map +1 -0
- package/dist/core/browser.js +318 -0
- package/dist/core/browser.js.map +1 -0
- package/dist/core/config.d.ts +20 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +103 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/config.test.d.ts +2 -0
- package/dist/core/config.test.d.ts.map +1 -0
- package/dist/core/config.test.js +111 -0
- package/dist/core/config.test.js.map +1 -0
- package/dist/core/storage.d.ts +19 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/storage.js +124 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/core/storage.test.d.ts +2 -0
- package/dist/core/storage.test.d.ts.map +1 -0
- package/dist/core/storage.test.js +142 -0
- package/dist/core/storage.test.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/linkedin/auth.d.ts +22 -0
- package/dist/linkedin/auth.d.ts.map +1 -0
- package/dist/linkedin/auth.js +167 -0
- package/dist/linkedin/auth.js.map +1 -0
- package/dist/linkedin/company-extractor.d.ts +36 -0
- package/dist/linkedin/company-extractor.d.ts.map +1 -0
- package/dist/linkedin/company-extractor.js +211 -0
- package/dist/linkedin/company-extractor.js.map +1 -0
- package/dist/linkedin/company-extractor.test.d.ts +2 -0
- package/dist/linkedin/company-extractor.test.d.ts.map +1 -0
- package/dist/linkedin/company-extractor.test.js +52 -0
- package/dist/linkedin/company-extractor.test.js.map +1 -0
- package/dist/linkedin/connector.d.ts +45 -0
- package/dist/linkedin/connector.d.ts.map +1 -0
- package/dist/linkedin/connector.js +245 -0
- package/dist/linkedin/connector.js.map +1 -0
- package/dist/linkedin/message-sender.d.ts +32 -0
- package/dist/linkedin/message-sender.d.ts.map +1 -0
- package/dist/linkedin/message-sender.js +112 -0
- package/dist/linkedin/message-sender.js.map +1 -0
- package/dist/linkedin/messages.d.ts +78 -0
- package/dist/linkedin/messages.d.ts.map +1 -0
- package/dist/linkedin/messages.js +745 -0
- package/dist/linkedin/messages.js.map +1 -0
- package/dist/linkedin/profile.d.ts +37 -0
- package/dist/linkedin/profile.d.ts.map +1 -0
- package/dist/linkedin/profile.js +268 -0
- package/dist/linkedin/profile.js.map +1 -0
- package/dist/linkedin/profile.test.d.ts +2 -0
- package/dist/linkedin/profile.test.d.ts.map +1 -0
- package/dist/linkedin/profile.test.js +68 -0
- package/dist/linkedin/profile.test.js.map +1 -0
- package/dist/linkedin/reply.d.ts +21 -0
- package/dist/linkedin/reply.d.ts.map +1 -0
- package/dist/linkedin/reply.js +76 -0
- package/dist/linkedin/reply.js.map +1 -0
- package/dist/linkedin/selector-engine.d.ts +69 -0
- package/dist/linkedin/selector-engine.d.ts.map +1 -0
- package/dist/linkedin/selector-engine.js +339 -0
- package/dist/linkedin/selector-engine.js.map +1 -0
- package/dist/linkedin/selector-engine.test.d.ts +2 -0
- package/dist/linkedin/selector-engine.test.d.ts.map +1 -0
- package/dist/linkedin/selector-engine.test.js +135 -0
- package/dist/linkedin/selector-engine.test.js.map +1 -0
- package/dist/linkedin/selectors.d.ts +65 -0
- package/dist/linkedin/selectors.d.ts.map +1 -0
- package/dist/linkedin/selectors.js +261 -0
- package/dist/linkedin/selectors.js.map +1 -0
- package/dist/templates/engine.d.ts +37 -0
- package/dist/templates/engine.d.ts.map +1 -0
- package/dist/templates/engine.js +215 -0
- package/dist/templates/engine.js.map +1 -0
- package/dist/templates/engine.test.d.ts +2 -0
- package/dist/templates/engine.test.d.ts.map +1 -0
- package/dist/templates/engine.test.js +212 -0
- package/dist/templates/engine.test.js.map +1 -0
- package/dist/templates/index.d.ts +2 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +7 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/types/index.d.ts +113 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/index.test.d.ts +2 -0
- package/dist/types/index.test.d.ts.map +1 -0
- package/dist/types/index.test.js +90 -0
- package/dist/types/index.test.js.map +1 -0
- package/dist/utils/paths.d.ts +8 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +68 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +22 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +57 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- package/dist/utils/retry.d.ts +18 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/retry.js +49 -0
- package/dist/utils/retry.js.map +1 -0
- package/docs/connection-command.md +52 -0
- package/docs/plans/2025-03-03-linkedin-cli-design.md +280 -0
- package/docs/plans/2025-03-03-linkedin-cli-implementation-plan.md +2087 -0
- package/docs/plans/2025-03-03-linkedin-cli-implementation.md +2420 -0
- package/docs/plans/2026-02-19-linkedin-connection-feature.md +596 -0
- package/docs/plans/2026-02-28-messages-send-feature.md +480 -0
- package/docs/plans/2026-02-28-messages-show-design.md +243 -0
- package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-design.md +394 -0
- package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-plan.md +1592 -0
- package/docs/superpowers/plans/2026-03-13-linkedin-automation-resilience-migration.md +425 -0
- package/docs/superpowers/plans/2026-03-13-playwright-fara-migration.md +1112 -0
- package/docs/superpowers/plans/2026-03-14-page-agent-plan.md +1598 -0
- package/docs/superpowers/plans/2026-03-15-company-profile-extraction.md +591 -0
- package/docs/superpowers/plans/2026-03-15-profile-extraction-plan.md +943 -0
- package/docs/superpowers/specs/2026-03-14-company-profile-extraction-design.md +371 -0
- package/docs/superpowers/specs/2026-03-14-page-agent-design.md +385 -0
- package/docs/superpowers/specs/2026-03-15-profile-extraction-design.md +409 -0
- package/eslint.config.mjs +58 -0
- package/go.mod +9 -0
- package/go.sum +10 -0
- package/import-cookies.js +376 -0
- package/internal/cmd/actions.go +123 -0
- package/internal/cmd/auth.go +108 -0
- package/internal/cmd/connect.go +42 -0
- package/internal/cmd/message.go +44 -0
- package/internal/cmd/people.go +454 -0
- package/internal/cmd/profiles.go +121 -0
- package/internal/cmd/root.go +89 -0
- package/internal/cmd/sequence.go +192 -0
- package/internal/config/config.go +187 -0
- package/internal/config/config_test.go +121 -0
- package/internal/config/profile.go +65 -0
- package/internal/linkedin/navigator.go +195 -0
- package/internal/linkedin/selectors.go +39 -0
- package/internal/linkedin/validator.go +69 -0
- package/internal/pinchtab/client.go +183 -0
- package/internal/pinchtab/client_test.go +67 -0
- package/internal/pinchtab/types.go +50 -0
- package/internal/ratelimit/limiter.go +115 -0
- package/internal/ratelimit/limits.go +32 -0
- package/package.json +67 -0
- package/release.sh +66 -0
- package/scripts/debug-linkedin.js +156 -0
- package/scripts/debug-login.js +193 -0
- package/scripts/extract-from-edge.js +96 -0
- package/scripts/import-cookies.js +101 -0
- package/scripts/poc-show-data.js +205 -0
- package/scripts/proof-of-access.js +87 -0
- package/scripts/prove-connection.js +110 -0
- package/scripts/show-linkedin-data.js +173 -0
- package/src/agent/action-executor.test.ts +464 -0
- package/src/agent/action-executor.ts +203 -0
- package/src/agent/claude-client.test.ts +707 -0
- package/src/agent/claude-client.ts +422 -0
- package/src/agent/dom-extractor.test.ts +574 -0
- package/src/agent/dom-extractor.ts +437 -0
- package/src/agent/extension-client.ts +306 -0
- package/src/agent/index.ts +28 -0
- package/src/agent/page-agent.ts +292 -0
- package/src/agent/types.ts +288 -0
- package/src/cli/agent-commands.ts +274 -0
- package/src/cli/auth.ts +343 -0
- package/src/cli/company.ts +66 -0
- package/src/cli/connection.ts +89 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/messages.ts +338 -0
- package/src/cli/profile.test.ts +14 -0
- package/src/cli/profile.ts +95 -0
- package/src/cli/reply.ts +110 -0
- package/src/core/audit.test.ts +134 -0
- package/src/core/audit.ts +98 -0
- package/src/core/browser-cookies.ts +203 -0
- package/src/core/browser.ts +304 -0
- package/src/core/config.test.ts +90 -0
- package/src/core/config.ts +81 -0
- package/src/core/storage.test.ts +129 -0
- package/src/core/storage.ts +100 -0
- package/src/index.ts +70 -0
- package/src/linkedin/auth.ts +218 -0
- package/src/linkedin/company-extractor.test.ts +58 -0
- package/src/linkedin/company-extractor.ts +222 -0
- package/src/linkedin/connector.ts +336 -0
- package/src/linkedin/message-sender.ts +141 -0
- package/src/linkedin/messages.ts +894 -0
- package/src/linkedin/profile.test.ts +79 -0
- package/src/linkedin/profile.ts +314 -0
- package/src/linkedin/reply.ts +96 -0
- package/src/linkedin/selector-engine.test.ts +167 -0
- package/src/linkedin/selector-engine.ts +393 -0
- package/src/linkedin/selectors.ts +268 -0
- package/src/templates/defaults/followup.txt +14 -0
- package/src/templates/defaults/meeting.txt +16 -0
- package/src/templates/defaults/welcome.txt +14 -0
- package/src/templates/engine.test.ts +228 -0
- package/src/templates/engine.ts +208 -0
- package/src/templates/index.ts +1 -0
- package/src/types/index.test.ts +94 -0
- package/src/types/index.ts +143 -0
- package/src/types/sql.js.d.ts +23 -0
- package/src/utils/paths.ts +33 -0
- package/src/utils/rate-limiter.ts +75 -0
- package/src/utils/retry.ts +78 -0
- package/test-cli.sh +85 -0
- package/test-real-data.sh +97 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +35 -0
|
@@ -0,0 +1,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)
|