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