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,2087 @@
|
|
|
1
|
+
# LinkedIn CLI Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build a Go CLI that wraps PinchTab HTTP API to automate LinkedIn connection requests and messaging with built-in rate limiting and safety features.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Go CLI using Cobra for commands, with packages for PinchTab HTTP client (`pinchtab/`), LinkedIn-specific selectors (`linkedin/`), rate limiting (`ratelimit/`), and configuration management (`config/`). JSON files for state persistence (no database for v1).
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Go 1.21+, Cobra (CLI), standard library for HTTP client, atomic file writes for state management.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Task 1: Project Setup
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Create: `go.mod`
|
|
17
|
+
- Create: `.gitignore`
|
|
18
|
+
- Create: `README.md` (basic)
|
|
19
|
+
|
|
20
|
+
**Step 1: Initialize Go module**
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd /Users/thaddeus/projects/linkedin-cli
|
|
24
|
+
go mod init github.com/thaddeus-git/linkedin-cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Step 2: Install Cobra CLI tool**
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
go install github.com/spf13/cobra-cli@latest
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Step 3: Initialize Cobra project**
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cobra-cli init --pkg-name github.com/thaddeus-git/linkedin-cli
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Step 4: Create directory structure**
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
mkdir -p internal/{cmd,config,pinchtab,linkedin,ratelimit}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Step 5: Create .gitignore**
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cat > .gitignore << 'EOF'
|
|
49
|
+
# Binaries
|
|
50
|
+
linkedin-cli
|
|
51
|
+
*.exe
|
|
52
|
+
*.dll
|
|
53
|
+
*.so
|
|
54
|
+
*.dylib
|
|
55
|
+
|
|
56
|
+
# Test binary
|
|
57
|
+
*.test
|
|
58
|
+
|
|
59
|
+
# Output of go coverage
|
|
60
|
+
coverage.out
|
|
61
|
+
|
|
62
|
+
# Dependency directories
|
|
63
|
+
vendor/
|
|
64
|
+
|
|
65
|
+
# IDE
|
|
66
|
+
.idea/
|
|
67
|
+
.vscode/
|
|
68
|
+
*.swp
|
|
69
|
+
*.swo
|
|
70
|
+
*~
|
|
71
|
+
|
|
72
|
+
# OS
|
|
73
|
+
.DS_Store
|
|
74
|
+
Thumbs.db
|
|
75
|
+
|
|
76
|
+
# Config/state (user data)
|
|
77
|
+
/.linkedin-cli/
|
|
78
|
+
EOF
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Step 6: Verify build works**
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
go build -o linkedin-cli .
|
|
85
|
+
./linkedin-cli --help
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Expected output:** Shows root command help with "A brief description..."
|
|
89
|
+
|
|
90
|
+
**Step 7: Commit**
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
git add .
|
|
94
|
+
git commit -m "chore: initialize Go project with Cobra"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Task 2: PinchTab HTTP Client - Core Types
|
|
100
|
+
|
|
101
|
+
**Files:**
|
|
102
|
+
- Create: `internal/pinchtab/types.go`
|
|
103
|
+
- Create: `internal/pinchtab/types_test.go`
|
|
104
|
+
|
|
105
|
+
**Step 1: Write types test**
|
|
106
|
+
|
|
107
|
+
```go
|
|
108
|
+
package pinchtab
|
|
109
|
+
|
|
110
|
+
import (
|
|
111
|
+
"testing"
|
|
112
|
+
"encoding/json"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
func TestInstanceResponseSerialization(t *testing.T) {
|
|
116
|
+
jsonData := `{"id":"inst_abc123","profileId":"test","port":9867,"status":"running"}`
|
|
117
|
+
var resp InstanceResponse
|
|
118
|
+
err := json.Unmarshal([]byte(jsonData), &resp)
|
|
119
|
+
if err != nil {
|
|
120
|
+
t.Fatalf("Failed to unmarshal: %v", err)
|
|
121
|
+
}
|
|
122
|
+
if resp.ID != "inst_abc123" {
|
|
123
|
+
t.Errorf("Expected ID 'inst_abc123', got '%s'", resp.ID)
|
|
124
|
+
}
|
|
125
|
+
if resp.Status != "running" {
|
|
126
|
+
t.Errorf("Expected status 'running', got '%s'", resp.Status)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func TestActionRequestSerialization(t *testing.T) {
|
|
131
|
+
req := ActionRequest{
|
|
132
|
+
Kind: "click",
|
|
133
|
+
Ref: "e5",
|
|
134
|
+
}
|
|
135
|
+
data, err := json.Marshal(req)
|
|
136
|
+
if err != nil {
|
|
137
|
+
t.Fatalf("Failed to marshal: %v", err)
|
|
138
|
+
}
|
|
139
|
+
expected := `{"kind":"click","ref":"e5"}`
|
|
140
|
+
if string(data) != expected {
|
|
141
|
+
t.Errorf("Expected '%s', got '%s'", expected, string(data))
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Step 2: Run test (should fail)**
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
go test ./internal/pinchtab/... -v
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Expected:** FAIL - undefined types
|
|
153
|
+
|
|
154
|
+
**Step 3: Implement types**
|
|
155
|
+
|
|
156
|
+
```go
|
|
157
|
+
package pinchtab
|
|
158
|
+
|
|
159
|
+
// InstanceResponse represents a PinchTab instance
|
|
160
|
+
type InstanceResponse struct {
|
|
161
|
+
ID string `json:"id"`
|
|
162
|
+
ProfileID string `json:"profileId"`
|
|
163
|
+
Port int `json:"port"`
|
|
164
|
+
Status string `json:"status"`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ActionRequest represents an action to perform
|
|
168
|
+
type ActionRequest struct {
|
|
169
|
+
Kind string `json:"kind"`
|
|
170
|
+
Ref string `json:"ref,omitempty"`
|
|
171
|
+
Value string `json:"value,omitempty"`
|
|
172
|
+
Key string `json:"key,omitempty"`
|
|
173
|
+
Amount int `json:"amount,omitempty"`
|
|
174
|
+
URL string `json:"url,omitempty"`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// SnapshotResponse represents page snapshot
|
|
178
|
+
type SnapshotResponse struct {
|
|
179
|
+
Title string `json:"title"`
|
|
180
|
+
URL string `json:"url"`
|
|
181
|
+
Elements []Element `json:"elements"`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Element represents an interactive element
|
|
185
|
+
type Element struct {
|
|
186
|
+
Ref string `json:"ref"`
|
|
187
|
+
Type string `json:"type"`
|
|
188
|
+
Text string `json:"text"`
|
|
189
|
+
Selector string `json:"selector,omitempty"`
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// TextResponse represents extracted text
|
|
193
|
+
type TextResponse struct {
|
|
194
|
+
Text string `json:"text"`
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Step 4: Run test (should pass)**
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
go test ./internal/pinchtab/... -v
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Expected:** PASS
|
|
205
|
+
|
|
206
|
+
**Step 5: Commit**
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
git add .
|
|
210
|
+
git commit -m "feat(pinchtab): add core data types"
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Task 3: PinchTab HTTP Client - Client Implementation
|
|
216
|
+
|
|
217
|
+
**Files:**
|
|
218
|
+
- Create: `internal/pinchtab/client.go`
|
|
219
|
+
- Create: `internal/pinchtab/client_test.go`
|
|
220
|
+
|
|
221
|
+
**Step 1: Write client test**
|
|
222
|
+
|
|
223
|
+
```go
|
|
224
|
+
package pinchtab
|
|
225
|
+
|
|
226
|
+
import (
|
|
227
|
+
"encoding/json"
|
|
228
|
+
"net/http"
|
|
229
|
+
"net/http/httptest"
|
|
230
|
+
"testing"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
func TestClientStartInstance(t *testing.T) {
|
|
234
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
235
|
+
if r.URL.Path != "/instances" {
|
|
236
|
+
t.Errorf("Expected path '/instances', got '%s'", r.URL.Path)
|
|
237
|
+
}
|
|
238
|
+
if r.Method != "POST" {
|
|
239
|
+
t.Errorf("Expected POST, got '%s'", r.Method)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
resp := InstanceResponse{
|
|
243
|
+
ID: "inst_test123",
|
|
244
|
+
ProfileID: "test-profile",
|
|
245
|
+
Port: 9867,
|
|
246
|
+
Status: "running",
|
|
247
|
+
}
|
|
248
|
+
json.NewEncoder(w).Encode(resp)
|
|
249
|
+
}))
|
|
250
|
+
defer server.Close()
|
|
251
|
+
|
|
252
|
+
client := NewClient(server.URL)
|
|
253
|
+
instance, err := client.StartInstance("test-profile")
|
|
254
|
+
if err != nil {
|
|
255
|
+
t.Fatalf("StartInstance failed: %v", err)
|
|
256
|
+
}
|
|
257
|
+
if instance.ID != "inst_test123" {
|
|
258
|
+
t.Errorf("Expected ID 'inst_test123', got '%s'", instance.ID)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
func TestClientNavigate(t *testing.T) {
|
|
263
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
264
|
+
if r.URL.Path != "/instances/test-tab/navigate" {
|
|
265
|
+
t.Errorf("Expected navigate path, got '%s'", r.URL.Path)
|
|
266
|
+
}
|
|
267
|
+
w.WriteHeader(http.StatusOK)
|
|
268
|
+
}))
|
|
269
|
+
defer server.Close()
|
|
270
|
+
|
|
271
|
+
client := NewClient(server.URL)
|
|
272
|
+
err := client.Navigate("test-tab", "https://linkedin.com/in/test")
|
|
273
|
+
if err != nil {
|
|
274
|
+
t.Fatalf("Navigate failed: %v", err)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**Step 2: Run test (should fail)**
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
go test ./internal/pinchtab/... -v
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Expected:** FAIL - undefined functions
|
|
286
|
+
|
|
287
|
+
**Step 3: Implement client**
|
|
288
|
+
|
|
289
|
+
```go
|
|
290
|
+
package pinchtab
|
|
291
|
+
|
|
292
|
+
import (
|
|
293
|
+
"bytes"
|
|
294
|
+
"encoding/json"
|
|
295
|
+
"fmt"
|
|
296
|
+
"io"
|
|
297
|
+
"net/http"
|
|
298
|
+
"time"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
// Client is a PinchTab HTTP API client
|
|
302
|
+
type Client struct {
|
|
303
|
+
BaseURL string
|
|
304
|
+
HTTPClient *http.Client
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// NewClient creates a new PinchTab client
|
|
308
|
+
func NewClient(baseURL string) *Client {
|
|
309
|
+
return &Client{
|
|
310
|
+
BaseURL: baseURL,
|
|
311
|
+
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// StartInstance starts a new PinchTab instance with the given profile
|
|
316
|
+
func (c *Client) StartInstance(profileID string) (*InstanceResponse, error) {
|
|
317
|
+
reqBody := map[string]string{"profileId": profileID}
|
|
318
|
+
data, _ := json.Marshal(reqBody)
|
|
319
|
+
|
|
320
|
+
resp, err := c.HTTPClient.Post(
|
|
321
|
+
c.BaseURL+"/instances",
|
|
322
|
+
"application/json",
|
|
323
|
+
bytes.NewReader(data),
|
|
324
|
+
)
|
|
325
|
+
if err != nil {
|
|
326
|
+
return nil, fmt.Errorf("failed to start instance: %w", err)
|
|
327
|
+
}
|
|
328
|
+
defer resp.Body.Close()
|
|
329
|
+
|
|
330
|
+
if resp.StatusCode != http.StatusOK {
|
|
331
|
+
body, _ := io.ReadAll(resp.Body)
|
|
332
|
+
return nil, fmt.Errorf("pinchtab error: %s - %s", resp.Status, string(body))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
var instance InstanceResponse
|
|
336
|
+
if err := json.NewDecoder(resp.Body).Decode(&instance); err != nil {
|
|
337
|
+
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return &instance, nil
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Navigate navigates a tab to a URL
|
|
344
|
+
func (c *Client) Navigate(tabID, url string) error {
|
|
345
|
+
reqBody := map[string]string{"url": url}
|
|
346
|
+
data, _ := json.Marshal(reqBody)
|
|
347
|
+
|
|
348
|
+
resp, err := c.HTTPClient.Post(
|
|
349
|
+
fmt.Sprintf("%s/instances/%s/navigate", c.BaseURL, tabID),
|
|
350
|
+
"application/json",
|
|
351
|
+
bytes.NewReader(data),
|
|
352
|
+
)
|
|
353
|
+
if err != nil {
|
|
354
|
+
return fmt.Errorf("failed to navigate: %w", err)
|
|
355
|
+
}
|
|
356
|
+
defer resp.Body.Close()
|
|
357
|
+
|
|
358
|
+
if resp.StatusCode != http.StatusOK {
|
|
359
|
+
body, _ := io.ReadAll(resp.Body)
|
|
360
|
+
return fmt.Errorf("pinchtab error: %s - %s", resp.Status, string(body))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return nil
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// GetSnapshot gets the page accessibility snapshot
|
|
367
|
+
func (c *Client) GetSnapshot(tabID string) (*SnapshotResponse, error) {
|
|
368
|
+
resp, err := c.HTTPClient.Get(
|
|
369
|
+
fmt.Sprintf("%s/instances/%s/snapshot", c.BaseURL, tabID),
|
|
370
|
+
)
|
|
371
|
+
if err != nil {
|
|
372
|
+
return nil, fmt.Errorf("failed to get snapshot: %w", err)
|
|
373
|
+
}
|
|
374
|
+
defer resp.Body.Close()
|
|
375
|
+
|
|
376
|
+
if resp.StatusCode != http.StatusOK {
|
|
377
|
+
body, _ := io.ReadAll(resp.Body)
|
|
378
|
+
return nil, fmt.Errorf("pinchtab error: %s - %s", resp.Status, string(body))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
var snapshot SnapshotResponse
|
|
382
|
+
if err := json.NewDecoder(resp.Body).Decode(&snapshot); err != nil {
|
|
383
|
+
return nil, fmt.Errorf("failed to decode snapshot: %w", err)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return &snapshot, nil
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ExecuteAction executes an action on an element
|
|
390
|
+
func (c *Client) ExecuteAction(tabID string, action ActionRequest) error {
|
|
391
|
+
data, _ := json.Marshal(action)
|
|
392
|
+
|
|
393
|
+
resp, err := c.HTTPClient.Post(
|
|
394
|
+
fmt.Sprintf("%s/instances/%s/actions", c.BaseURL, tabID),
|
|
395
|
+
"application/json",
|
|
396
|
+
bytes.NewReader(data),
|
|
397
|
+
)
|
|
398
|
+
if err != nil {
|
|
399
|
+
return fmt.Errorf("failed to execute action: %w", err)
|
|
400
|
+
}
|
|
401
|
+
defer resp.Body.Close()
|
|
402
|
+
|
|
403
|
+
if resp.StatusCode != http.StatusOK {
|
|
404
|
+
body, _ := io.ReadAll(resp.Body)
|
|
405
|
+
return fmt.Errorf("pinchtab error: %s - %s", resp.Status, string(body))
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return nil
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ExtractText extracts text from the page
|
|
412
|
+
func (c *Client) ExtractText(tabID string) (*TextResponse, error) {
|
|
413
|
+
resp, err := c.HTTPClient.Get(
|
|
414
|
+
fmt.Sprintf("%s/instances/%s/text", c.BaseURL, tabID),
|
|
415
|
+
)
|
|
416
|
+
if err != nil {
|
|
417
|
+
return nil, fmt.Errorf("failed to extract text: %w", err)
|
|
418
|
+
}
|
|
419
|
+
defer resp.Body.Close()
|
|
420
|
+
|
|
421
|
+
if resp.StatusCode != http.StatusOK {
|
|
422
|
+
body, _ := io.ReadAll(resp.Body)
|
|
423
|
+
return nil, fmt.Errorf("pinchtab error: %s - %s", resp.Status, string(body))
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
var textResp TextResponse
|
|
427
|
+
if err := json.NewDecoder(resp.Body).Decode(&textResp); err != nil {
|
|
428
|
+
return nil, fmt.Errorf("failed to decode text: %w", err)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return &textResp, nil
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// StopInstance stops a PinchTab instance
|
|
435
|
+
func (c *Client) StopInstance(instanceID string) error {
|
|
436
|
+
req, err := http.NewRequest(http.MethodPost,
|
|
437
|
+
fmt.Sprintf("%s/instances/%s/stop", c.BaseURL, instanceID),
|
|
438
|
+
nil,
|
|
439
|
+
)
|
|
440
|
+
if err != nil {
|
|
441
|
+
return fmt.Errorf("failed to create stop request: %w", err)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
resp, err := c.HTTPClient.Do(req)
|
|
445
|
+
if err != nil {
|
|
446
|
+
return fmt.Errorf("failed to stop instance: %w", err)
|
|
447
|
+
}
|
|
448
|
+
defer resp.Body.Close()
|
|
449
|
+
|
|
450
|
+
if resp.StatusCode != http.StatusOK {
|
|
451
|
+
body, _ := io.ReadAll(resp.Body)
|
|
452
|
+
return fmt.Errorf("pinchtab error: %s - %s", resp.Status, string(body))
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return nil
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
**Step 4: Run test (should pass)**
|
|
460
|
+
|
|
461
|
+
```bash
|
|
462
|
+
go test ./internal/pinchtab/... -v
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
**Expected:** PASS
|
|
466
|
+
|
|
467
|
+
**Step 5: Commit**
|
|
468
|
+
|
|
469
|
+
```bash
|
|
470
|
+
git add .
|
|
471
|
+
git commit -m "feat(pinchtab): implement HTTP client"
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
476
|
+
## Task 4: Configuration Management
|
|
477
|
+
|
|
478
|
+
**Files:**
|
|
479
|
+
- Create: `internal/config/config.go`
|
|
480
|
+
- Create: `internal/config/config_test.go`
|
|
481
|
+
|
|
482
|
+
**Step 1: Write config test**
|
|
483
|
+
|
|
484
|
+
```go
|
|
485
|
+
package config
|
|
486
|
+
|
|
487
|
+
import (
|
|
488
|
+
"os"
|
|
489
|
+
"path/filepath"
|
|
490
|
+
"testing"
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
func TestGetConfigDir(t *testing.T) {
|
|
494
|
+
dir, err := GetConfigDir()
|
|
495
|
+
if err != nil {
|
|
496
|
+
t.Fatalf("GetConfigDir failed: %v", err)
|
|
497
|
+
}
|
|
498
|
+
if dir == "" {
|
|
499
|
+
t.Error("Config dir should not be empty")
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
func TestProfileSaveAndLoad(t *testing.T) {
|
|
504
|
+
// Use temp dir for testing
|
|
505
|
+
tmpDir := t.TempDir()
|
|
506
|
+
|
|
507
|
+
profile := &Profile{
|
|
508
|
+
Name: "test",
|
|
509
|
+
PinchTabProfile: "linkedin-test",
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Save
|
|
513
|
+
path := filepath.Join(tmpDir, "test.json")
|
|
514
|
+
err := profile.Save(path)
|
|
515
|
+
if err != nil {
|
|
516
|
+
t.Fatalf("Save failed: %v", err)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Load
|
|
520
|
+
loaded, err := LoadProfile(path)
|
|
521
|
+
if err != nil {
|
|
522
|
+
t.Fatalf("LoadProfile failed: %v", err)
|
|
523
|
+
}
|
|
524
|
+
if loaded.Name != "test" {
|
|
525
|
+
t.Errorf("Expected name 'test', got '%s'", loaded.Name)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
**Step 2: Run test (should fail)**
|
|
531
|
+
|
|
532
|
+
```bash
|
|
533
|
+
go test ./internal/config/... -v
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
**Expected:** FAIL - undefined types
|
|
537
|
+
|
|
538
|
+
**Step 3: Implement config**
|
|
539
|
+
|
|
540
|
+
```go
|
|
541
|
+
package config
|
|
542
|
+
|
|
543
|
+
import (
|
|
544
|
+
"encoding/json"
|
|
545
|
+
"fmt"
|
|
546
|
+
"os"
|
|
547
|
+
"path/filepath"
|
|
548
|
+
"time"
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
// Profile represents a LinkedIn profile configuration
|
|
552
|
+
type Profile struct {
|
|
553
|
+
Name string `json:"name"`
|
|
554
|
+
PinchTabProfile string `json:"pinchtab_profile"`
|
|
555
|
+
CreatedAt time.Time `json:"created_at"`
|
|
556
|
+
LastUsed time.Time `json:"last_used"`
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// GetConfigDir returns the configuration directory
|
|
560
|
+
func GetConfigDir() (string, error) {
|
|
561
|
+
home, err := os.UserHomeDir()
|
|
562
|
+
if err != nil {
|
|
563
|
+
return "", fmt.Errorf("failed to get home directory: %w", err)
|
|
564
|
+
}
|
|
565
|
+
return filepath.Join(home, ".linkedin-cli"), nil
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// GetProfilesDir returns the profiles directory
|
|
569
|
+
func GetProfilesDir() (string, error) {
|
|
570
|
+
configDir, err := GetConfigDir()
|
|
571
|
+
if err != nil {
|
|
572
|
+
return "", err
|
|
573
|
+
}
|
|
574
|
+
return filepath.Join(configDir, "profiles"), nil
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ProfilePath returns the path for a profile file
|
|
578
|
+
func ProfilePath(name string) (string, error) {
|
|
579
|
+
profilesDir, err := GetProfilesDir()
|
|
580
|
+
if err != nil {
|
|
581
|
+
return "", err
|
|
582
|
+
}
|
|
583
|
+
return filepath.Join(profilesDir, name+".json"), nil
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Save writes the profile to disk
|
|
587
|
+
func (p *Profile) Save(path string) error {
|
|
588
|
+
data, err := json.MarshalIndent(p, "", " ")
|
|
589
|
+
if err != nil {
|
|
590
|
+
return fmt.Errorf("failed to marshal profile: %w", err)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
dir := filepath.Dir(path)
|
|
594
|
+
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
595
|
+
return fmt.Errorf("failed to create directory: %w", err)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
599
|
+
return fmt.Errorf("failed to write profile: %w", err)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return nil
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// LoadProfile loads a profile from disk
|
|
606
|
+
func LoadProfile(path string) (*Profile, error) {
|
|
607
|
+
data, err := os.ReadFile(path)
|
|
608
|
+
if err != nil {
|
|
609
|
+
return nil, fmt.Errorf("failed to read profile: %w", err)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
var profile Profile
|
|
613
|
+
if err := json.Unmarshal(data, &profile); err != nil {
|
|
614
|
+
return nil, fmt.Errorf("failed to unmarshal profile: %w", err)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return &profile, nil
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ListProfiles returns all available profile names
|
|
621
|
+
func ListProfiles() ([]string, error) {
|
|
622
|
+
profilesDir, err := GetProfilesDir()
|
|
623
|
+
if err != nil {
|
|
624
|
+
return nil, err
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
entries, err := os.ReadDir(profilesDir)
|
|
628
|
+
if err != nil {
|
|
629
|
+
if os.IsNotExist(err) {
|
|
630
|
+
return []string{}, nil
|
|
631
|
+
}
|
|
632
|
+
return nil, fmt.Errorf("failed to read profiles directory: %w", err)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
var profiles []string
|
|
636
|
+
for _, entry := range entries {
|
|
637
|
+
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
|
|
638
|
+
name := entry.Name()
|
|
639
|
+
name = name[:len(name)-5] // Remove .json
|
|
640
|
+
profiles = append(profiles, name)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return profiles, nil
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ProfileExists checks if a profile exists
|
|
648
|
+
func ProfileExists(name string) (bool, error) {
|
|
649
|
+
path, err := ProfilePath(name)
|
|
650
|
+
if err != nil {
|
|
651
|
+
return false, err
|
|
652
|
+
}
|
|
653
|
+
_, err = os.Stat(path)
|
|
654
|
+
if os.IsNotExist(err) {
|
|
655
|
+
return false, nil
|
|
656
|
+
}
|
|
657
|
+
return err == nil, err
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// DeleteProfile removes a profile
|
|
661
|
+
func DeleteProfile(name string) error {
|
|
662
|
+
path, err := ProfilePath(name)
|
|
663
|
+
if err != nil {
|
|
664
|
+
return err
|
|
665
|
+
}
|
|
666
|
+
if err := os.Remove(path); err != nil {
|
|
667
|
+
return fmt.Errorf("failed to delete profile: %w", err)
|
|
668
|
+
}
|
|
669
|
+
return nil
|
|
670
|
+
}
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
**Step 4: Run test (should pass)**
|
|
674
|
+
|
|
675
|
+
```bash
|
|
676
|
+
go test ./internal/config/... -v
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
**Expected:** PASS
|
|
680
|
+
|
|
681
|
+
**Step 5: Commit**
|
|
682
|
+
|
|
683
|
+
```bash
|
|
684
|
+
git add .
|
|
685
|
+
git commit -m "feat(config): add profile management"
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
---
|
|
689
|
+
|
|
690
|
+
## Task 5: Rate Limiting
|
|
691
|
+
|
|
692
|
+
**Files:**
|
|
693
|
+
- Create: `internal/ratelimit/limiter.go`
|
|
694
|
+
- Create: `internal/ratelimit/limiter_test.go`
|
|
695
|
+
|
|
696
|
+
**Step 1: Write rate limit test**
|
|
697
|
+
|
|
698
|
+
```go
|
|
699
|
+
package ratelimit
|
|
700
|
+
|
|
701
|
+
import (
|
|
702
|
+
"testing"
|
|
703
|
+
"time"
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
func TestCheckConnectionLimit(t *testing.T) {
|
|
707
|
+
rateFile := "/tmp/test_ratellmit.json"
|
|
708
|
+
limiter := New(rateFile)
|
|
709
|
+
|
|
710
|
+
// Should allow first 20 connections
|
|
711
|
+
for i := 0; i < 20; i++ {
|
|
712
|
+
ok, err := limiter.CheckConnection("test-profile")
|
|
713
|
+
if err != nil {
|
|
714
|
+
t.Fatalf("CheckConnection failed: %v", err)
|
|
715
|
+
}
|
|
716
|
+
if !ok {
|
|
717
|
+
t.Errorf("Should allow connection %d", i+1)
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// 21st should be blocked
|
|
722
|
+
ok, err := limiter.CheckConnection("test-profile")
|
|
723
|
+
if err != nil {
|
|
724
|
+
t.Fatalf("CheckConnection failed: %v", err)
|
|
725
|
+
}
|
|
726
|
+
if ok {
|
|
727
|
+
t.Error("Should block 21st connection")
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
**Step 2: Run test (should fail)**
|
|
733
|
+
|
|
734
|
+
```bash
|
|
735
|
+
go test ./internal/ratelimit/... -v
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
**Expected:** FAIL - undefined types
|
|
739
|
+
|
|
740
|
+
**Step 3: Implement rate limiter**
|
|
741
|
+
|
|
742
|
+
```go
|
|
743
|
+
package ratelimit
|
|
744
|
+
|
|
745
|
+
import (
|
|
746
|
+
"encoding/json"
|
|
747
|
+
"fmt"
|
|
748
|
+
"os"
|
|
749
|
+
"path/filepath"
|
|
750
|
+
"sync"
|
|
751
|
+
"time"
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
// Limits defines rate limits for a profile
|
|
755
|
+
type Limits struct {
|
|
756
|
+
ConnectionsToday int `json:"connections_today"`
|
|
757
|
+
ConnectionsWeek int `json:"connections_week"`
|
|
758
|
+
MessagesToday int `json:"messages_today"`
|
|
759
|
+
LastConnectionTime time.Time `json:"last_connection_time"`
|
|
760
|
+
LastMessageTime time.Time `json:"last_message_time"`
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Limiter manages rate limits for profiles
|
|
764
|
+
type Limiter struct {
|
|
765
|
+
stateFile string
|
|
766
|
+
limits map[string]*Limits
|
|
767
|
+
mu sync.RWMutex
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Default limits (LinkedIn-safe)
|
|
771
|
+
const (
|
|
772
|
+
MaxConnectionsPerDay = 20
|
|
773
|
+
MaxConnectionsPerWeek = 100
|
|
774
|
+
MaxMessagesPerDay = 50
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
// New creates a new rate limiter
|
|
778
|
+
func New(stateFile string) *Limiter {
|
|
779
|
+
return &Limiter{
|
|
780
|
+
stateFile: stateFile,
|
|
781
|
+
limits: make(map[string]*Limits),
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Load reads rate limit state from disk
|
|
786
|
+
func (l *Limiter) Load() error {
|
|
787
|
+
l.mu.Lock()
|
|
788
|
+
defer l.mu.Unlock()
|
|
789
|
+
|
|
790
|
+
data, err := os.ReadFile(l.stateFile)
|
|
791
|
+
if err != nil {
|
|
792
|
+
if os.IsNotExist(err) {
|
|
793
|
+
return nil // No state yet
|
|
794
|
+
}
|
|
795
|
+
return fmt.Errorf("failed to read rate limit file: %w", err)
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if err := json.Unmarshal(data, &l.limits); err != nil {
|
|
799
|
+
return fmt.Errorf("failed to unmarshal rate limits: %w", err)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return nil
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Save writes rate limit state to disk
|
|
806
|
+
func (l *Limiter) Save() error {
|
|
807
|
+
l.mu.RLock()
|
|
808
|
+
data, err := json.MarshalIndent(l.limits, "", " ")
|
|
809
|
+
l.mu.RUnlock()
|
|
810
|
+
|
|
811
|
+
if err != nil {
|
|
812
|
+
return fmt.Errorf("failed to marshal rate limits: %w", err)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
dir := filepath.Dir(l.stateFile)
|
|
816
|
+
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
817
|
+
return fmt.Errorf("failed to create directory: %w", err)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if err := os.WriteFile(l.stateFile, data, 0600); err != nil {
|
|
821
|
+
return fmt.Errorf("failed to write rate limits: %w", err)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return nil
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// resetIfNeeded resets counters if day/week has changed
|
|
828
|
+
func (l *Limiter) resetIfNeeded(profile string) {
|
|
829
|
+
limits, exists := l.limits[profile]
|
|
830
|
+
if !exists {
|
|
831
|
+
l.limits[profile] = &Limits{}
|
|
832
|
+
return
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
now := time.Now()
|
|
836
|
+
|
|
837
|
+
// Reset daily counters if it's a new day
|
|
838
|
+
if !limits.LastConnectionTime.IsZero() &&
|
|
839
|
+
limits.LastConnectionTime.YearDay() != now.YearDay() {
|
|
840
|
+
limits.ConnectionsToday = 0
|
|
841
|
+
limits.MessagesToday = 0
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Reset weekly counter if it's a new week
|
|
845
|
+
if !limits.LastConnectionTime.IsZero() {
|
|
846
|
+
_, lastWeek := limits.LastConnectionTime.ISOWeek()
|
|
847
|
+
_, thisWeek := now.ISOWeek()
|
|
848
|
+
if lastWeek != thisWeek {
|
|
849
|
+
limits.ConnectionsWeek = 0
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// CheckConnection checks if a connection request is allowed
|
|
855
|
+
func (l *Limiter) CheckConnection(profile string) (bool, error) {
|
|
856
|
+
l.mu.Lock()
|
|
857
|
+
defer l.mu.Unlock()
|
|
858
|
+
|
|
859
|
+
l.resetIfNeeded(profile)
|
|
860
|
+
limits := l.limits[profile]
|
|
861
|
+
|
|
862
|
+
if limits.ConnectionsToday >= MaxConnectionsPerDay {
|
|
863
|
+
return false, nil
|
|
864
|
+
}
|
|
865
|
+
if limits.ConnectionsWeek >= MaxConnectionsPerWeek {
|
|
866
|
+
return false, nil
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return true, nil
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// RecordConnection records a successful connection
|
|
873
|
+
func (l *Limiter) RecordConnection(profile string) error {
|
|
874
|
+
l.mu.Lock()
|
|
875
|
+
defer l.mu.Unlock()
|
|
876
|
+
|
|
877
|
+
l.resetIfNeeded(profile)
|
|
878
|
+
limits := l.limits[profile]
|
|
879
|
+
|
|
880
|
+
limits.ConnectionsToday++
|
|
881
|
+
limits.ConnectionsWeek++
|
|
882
|
+
limits.LastConnectionTime = time.Now()
|
|
883
|
+
|
|
884
|
+
return l.Save()
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// CheckMessage checks if a message is allowed
|
|
888
|
+
func (l *Limiter) CheckMessage(profile string) (bool, error) {
|
|
889
|
+
l.mu.Lock()
|
|
890
|
+
defer l.mu.Unlock()
|
|
891
|
+
|
|
892
|
+
l.resetIfNeeded(profile)
|
|
893
|
+
limits := l.limits[profile]
|
|
894
|
+
|
|
895
|
+
return limits.MessagesToday < MaxMessagesPerDay, nil
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// RecordMessage records a successful message
|
|
899
|
+
func (l *Limiter) RecordMessage(profile string) error {
|
|
900
|
+
l.mu.Lock()
|
|
901
|
+
defer l.mu.Unlock()
|
|
902
|
+
|
|
903
|
+
l.resetIfNeeded(profile)
|
|
904
|
+
limits := l.limits[profile]
|
|
905
|
+
|
|
906
|
+
limits.MessagesToday++
|
|
907
|
+
limits.LastMessageTime = time.Now()
|
|
908
|
+
|
|
909
|
+
return l.Save()
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// GetStats returns current usage stats for a profile
|
|
913
|
+
func (l *Limiter) GetStats(profile string) (connectionsToday, connectionsWeek, messagesToday int) {
|
|
914
|
+
l.mu.RLock()
|
|
915
|
+
defer l.mu.RUnlock()
|
|
916
|
+
|
|
917
|
+
limits, exists := l.limits[profile]
|
|
918
|
+
if !exists {
|
|
919
|
+
return 0, 0, 0
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return limits.ConnectionsToday, limits.ConnectionsWeek, limits.MessagesToday
|
|
923
|
+
}
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
**Step 4: Run test (should pass)**
|
|
927
|
+
|
|
928
|
+
```bash
|
|
929
|
+
go test ./internal/ratelimit/... -v
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
**Expected:** PASS
|
|
933
|
+
|
|
934
|
+
**Step 5: Commit**
|
|
935
|
+
|
|
936
|
+
```bash
|
|
937
|
+
git add .
|
|
938
|
+
git commit -m "feat(ratelimit): add rate limiting with LinkedIn-safe defaults"
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
---
|
|
942
|
+
|
|
943
|
+
## Task 6: LinkedIn Selectors
|
|
944
|
+
|
|
945
|
+
**Files:**
|
|
946
|
+
- Create: `internal/linkedin/selectors.go`
|
|
947
|
+
- Create: `internal/linkedin/validator.go`
|
|
948
|
+
|
|
949
|
+
**Step 1: Implement selectors**
|
|
950
|
+
|
|
951
|
+
```go
|
|
952
|
+
package linkedin
|
|
953
|
+
|
|
954
|
+
// Element selectors for LinkedIn pages
|
|
955
|
+
// Note: These are fragile and may break when LinkedIn updates their UI
|
|
956
|
+
|
|
957
|
+
const (
|
|
958
|
+
// Profile page selectors
|
|
959
|
+
ConnectButtonText = "Connect"
|
|
960
|
+
MessageButtonText = "Message"
|
|
961
|
+
PendingButtonText = "Pending"
|
|
962
|
+
|
|
963
|
+
// Connection modal selectors
|
|
964
|
+
ConnectModalNoteTextarea = "textarea[name='message']"
|
|
965
|
+
ConnectModalSendButton = "button[aria-label='Send now']"
|
|
966
|
+
|
|
967
|
+
// Message page selectors
|
|
968
|
+
MessageTextarea = "textarea.msg-form__contenteditable"
|
|
969
|
+
MessageSendButton = "button[type='submit']"
|
|
970
|
+
|
|
971
|
+
// Common selectors (accessibility refs)
|
|
972
|
+
RefConnectButton = "e0" // This will be dynamic, found via snapshot
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
// FindElementRef finds an element reference by text content
|
|
976
|
+
// Returns the ref (e.g., "e5") if found, empty string if not found
|
|
977
|
+
func FindElementRef(elements []Element, text string) string {
|
|
978
|
+
for _, el := range elements {
|
|
979
|
+
if el.Text == text {
|
|
980
|
+
return el.Ref
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return ""
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Element represents an interactive element from PinchTab snapshot
|
|
987
|
+
type Element struct {
|
|
988
|
+
Ref string `json:"ref"`
|
|
989
|
+
Type string `json:"type"`
|
|
990
|
+
Text string `json:"text"`
|
|
991
|
+
}
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
**Step 2: Implement validator**
|
|
995
|
+
|
|
996
|
+
```go
|
|
997
|
+
package linkedin
|
|
998
|
+
|
|
999
|
+
import (
|
|
1000
|
+
"fmt"
|
|
1001
|
+
"net/url"
|
|
1002
|
+
"strings"
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
// ValidateProfileURL checks if a URL is a valid LinkedIn profile URL
|
|
1006
|
+
func ValidateProfileURL(input string) error {
|
|
1007
|
+
if !strings.HasPrefix(input, "http") {
|
|
1008
|
+
input = "https://" + input
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
u, err := url.Parse(input)
|
|
1012
|
+
if err != nil {
|
|
1013
|
+
return fmt.Errorf("invalid URL: %w", err)
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Check domain
|
|
1017
|
+
if !strings.Contains(u.Host, "linkedin.com") {
|
|
1018
|
+
return fmt.Errorf("URL must be a linkedin.com domain")
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Check path patterns
|
|
1022
|
+
path := u.Path
|
|
1023
|
+
validPatterns := []string{
|
|
1024
|
+
"/in/",
|
|
1025
|
+
"/sales/lead/",
|
|
1026
|
+
"/sales/gmail/",
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
valid := false
|
|
1030
|
+
for _, pattern := range validPatterns {
|
|
1031
|
+
if strings.HasPrefix(path, pattern) {
|
|
1032
|
+
valid = true
|
|
1033
|
+
break
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if !valid {
|
|
1038
|
+
return fmt.Errorf("URL must be a LinkedIn profile URL (e.g., linkedin.com/in/username)")
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return nil
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// NormalizeProfileURL ensures the URL has the https:// prefix
|
|
1045
|
+
func NormalizeProfileURL(input string) string {
|
|
1046
|
+
if !strings.HasPrefix(input, "http") {
|
|
1047
|
+
return "https://" + input
|
|
1048
|
+
}
|
|
1049
|
+
return input
|
|
1050
|
+
}
|
|
1051
|
+
```
|
|
1052
|
+
|
|
1053
|
+
**Step 3: Commit**
|
|
1054
|
+
|
|
1055
|
+
```bash
|
|
1056
|
+
git add .
|
|
1057
|
+
git commit -m "feat(linkedin): add selectors and URL validation"
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
---
|
|
1061
|
+
|
|
1062
|
+
## Task 7: Auth Command
|
|
1063
|
+
|
|
1064
|
+
**Files:**
|
|
1065
|
+
- Create: `internal/cmd/auth.go`
|
|
1066
|
+
- Modify: `cmd/linkedin/main.go` to add auth command
|
|
1067
|
+
|
|
1068
|
+
**Step 1: Implement auth command**
|
|
1069
|
+
|
|
1070
|
+
```go
|
|
1071
|
+
package cmd
|
|
1072
|
+
|
|
1073
|
+
import (
|
|
1074
|
+
"fmt"
|
|
1075
|
+
"os"
|
|
1076
|
+
|
|
1077
|
+
"github.com/spf13/cobra"
|
|
1078
|
+
"github.com/thaddeus-git/linkedin-cli/internal/config"
|
|
1079
|
+
"github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
var authCmd = &cobra.Command{
|
|
1083
|
+
Use: "auth --profile NAME",
|
|
1084
|
+
Short: "Authenticate a LinkedIn profile",
|
|
1085
|
+
Long: `Creates a new profile and opens LinkedIn for manual authentication.`,
|
|
1086
|
+
RunE: runAuth,
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
func init() {
|
|
1090
|
+
authCmd.Flags().String("profile", "", "Profile name (required)")
|
|
1091
|
+
authCmd.MarkFlagRequired("profile")
|
|
1092
|
+
rootCmd.AddCommand(authCmd)
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
func runAuth(cmd *cobra.Command, args []string) error {
|
|
1096
|
+
profileName, _ := cmd.Flags().GetString("profile")
|
|
1097
|
+
pinchtabHost := os.Getenv("PINCHTAB_HOST")
|
|
1098
|
+
if pinchtabHost == "" {
|
|
1099
|
+
pinchtabHost = "http://localhost:9867"
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Check if PinchTab is running
|
|
1103
|
+
client := pinchtab.NewClient(pinchtabHost)
|
|
1104
|
+
|
|
1105
|
+
// Create profile config
|
|
1106
|
+
pinchtabProfileName := "linkedin-" + profileName
|
|
1107
|
+
profile := &config.Profile{
|
|
1108
|
+
Name: profileName,
|
|
1109
|
+
PinchTabProfile: pinchtabProfileName,
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
profilePath, err := config.ProfilePath(profileName)
|
|
1113
|
+
if err != nil {
|
|
1114
|
+
return fmt.Errorf("failed to get profile path: %w", err)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Check if profile already exists
|
|
1118
|
+
exists, _ := config.ProfileExists(profileName)
|
|
1119
|
+
if exists {
|
|
1120
|
+
fmt.Printf("Profile '%s' already exists. Overwrite? (y/N): ", profileName)
|
|
1121
|
+
var response string
|
|
1122
|
+
fmt.Scanln(&response)
|
|
1123
|
+
if response != "y" && response != "Y" {
|
|
1124
|
+
fmt.Println("Cancelled.")
|
|
1125
|
+
return nil
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
fmt.Printf("Starting LinkedIn authentication for profile '%s'...\n", profileName)
|
|
1130
|
+
fmt.Println("A browser window will open. Please:")
|
|
1131
|
+
fmt.Println("1. Log in to LinkedIn")
|
|
1132
|
+
fmt.Println("2. Complete any 2FA if required")
|
|
1133
|
+
fmt.Println("3. Keep the browser open until you see 'Authentication complete'")
|
|
1134
|
+
|
|
1135
|
+
// Start instance with headed mode
|
|
1136
|
+
instance, err := client.StartInstance(pinchtabProfileName)
|
|
1137
|
+
if err != nil {
|
|
1138
|
+
return fmt.Errorf("failed to start PinchTab: %w\nMake sure PinchTab is running: pinchtab start", err)
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Navigate to LinkedIn
|
|
1142
|
+
if err := client.Navigate(instance.ID, "https://linkedin.com/login"); err != nil {
|
|
1143
|
+
client.StopInstance(instance.ID)
|
|
1144
|
+
return fmt.Errorf("failed to navigate: %w", err)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
fmt.Printf("\nBrowser opened. Waiting for authentication...\n")
|
|
1148
|
+
fmt.Println("Press Enter when you've successfully logged in to LinkedIn:")
|
|
1149
|
+
fmt.Scanln()
|
|
1150
|
+
|
|
1151
|
+
// Save profile
|
|
1152
|
+
if err := profile.Save(profilePath); err != nil {
|
|
1153
|
+
client.StopInstance(instance.ID)
|
|
1154
|
+
return fmt.Errorf("failed to save profile: %w", err)
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Stop instance
|
|
1158
|
+
if err := client.StopInstance(instance.ID); err != nil {
|
|
1159
|
+
fmt.Fprintf(os.Stderr, "Warning: failed to stop instance: %v\n", err)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
fmt.Printf("\n✓ Profile '%s' authenticated successfully!\n", profileName)
|
|
1163
|
+
fmt.Printf("You can now use: linkedin connect --profile %s --url <profile-url>\n", profileName)
|
|
1164
|
+
|
|
1165
|
+
return nil
|
|
1166
|
+
}
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
**Step 2: Add auth command to main.go**
|
|
1170
|
+
|
|
1171
|
+
The Cobra init should auto-register, but ensure `cmd/auth.go` imports are correct.
|
|
1172
|
+
|
|
1173
|
+
**Step 3: Test build**
|
|
1174
|
+
|
|
1175
|
+
```bash
|
|
1176
|
+
go build -o linkedin-cli .
|
|
1177
|
+
./linkedin-cli auth --help
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
**Expected:** Shows auth command help
|
|
1181
|
+
|
|
1182
|
+
**Step 4: Commit**
|
|
1183
|
+
|
|
1184
|
+
```bash
|
|
1185
|
+
git add .
|
|
1186
|
+
git commit -m "feat(cmd): add auth command for profile authentication"
|
|
1187
|
+
```
|
|
1188
|
+
|
|
1189
|
+
---
|
|
1190
|
+
|
|
1191
|
+
## Task 8: Connect Command
|
|
1192
|
+
|
|
1193
|
+
**Files:**
|
|
1194
|
+
- Create: `internal/cmd/connect.go`
|
|
1195
|
+
|
|
1196
|
+
**Step 1: Implement connect command**
|
|
1197
|
+
|
|
1198
|
+
```go
|
|
1199
|
+
package cmd
|
|
1200
|
+
|
|
1201
|
+
import (
|
|
1202
|
+
"fmt"
|
|
1203
|
+
"os"
|
|
1204
|
+
"time"
|
|
1205
|
+
|
|
1206
|
+
"github.com/spf13/cobra"
|
|
1207
|
+
"github.com/thaddeus-git/linkedin-cli/internal/config"
|
|
1208
|
+
"github.com/thaddeus-git/linkedin-cli/internal/linkedin"
|
|
1209
|
+
"github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
|
|
1210
|
+
"github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
var connectCmd = &cobra.Command{
|
|
1214
|
+
Use: "connect --profile NAME --url URL [--message TEXT]",
|
|
1215
|
+
Short: "Send a connection request",
|
|
1216
|
+
Long: `Sends a LinkedIn connection request to the specified profile URL.`,
|
|
1217
|
+
RunE: runConnect,
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
func init() {
|
|
1221
|
+
connectCmd.Flags().String("profile", "", "Profile name (required)")
|
|
1222
|
+
connectCmd.Flags().String("url", "", "LinkedIn profile URL (required)")
|
|
1223
|
+
connectCmd.Flags().String("message", "", "Connection note (optional)")
|
|
1224
|
+
connectCmd.Flags().Bool("dry-run", false, "Show what would be done without executing")
|
|
1225
|
+
connectCmd.Flags().Bool("yes", false, "Skip confirmation prompt")
|
|
1226
|
+
connectCmd.MarkFlagRequired("profile")
|
|
1227
|
+
connectCmd.MarkFlagRequired("url")
|
|
1228
|
+
rootCmd.AddCommand(connectCmd)
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
func runConnect(cmd *cobra.Command, args []string) error {
|
|
1232
|
+
profileName, _ := cmd.Flags().GetString("profile")
|
|
1233
|
+
url, _ := cmd.Flags().GetString("url")
|
|
1234
|
+
message, _ := cmd.Flags().GetString("message")
|
|
1235
|
+
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
1236
|
+
yes, _ := cmd.Flags().GetBool("yes")
|
|
1237
|
+
|
|
1238
|
+
pinchtabHost := os.Getenv("PINCHTAB_HOST")
|
|
1239
|
+
if pinchtabHost == "" {
|
|
1240
|
+
pinchtabHost = "http://localhost:9867"
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Validate URL
|
|
1244
|
+
if err := linkedin.ValidateProfileURL(url); err != nil {
|
|
1245
|
+
return err
|
|
1246
|
+
}
|
|
1247
|
+
url = linkedin.NormalizeProfileURL(url)
|
|
1248
|
+
|
|
1249
|
+
// Load profile
|
|
1250
|
+
profilePath, err := config.ProfilePath(profileName)
|
|
1251
|
+
if err != nil {
|
|
1252
|
+
return fmt.Errorf("failed to get profile path: %w", err)
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
profile, err := config.LoadProfile(profilePath)
|
|
1256
|
+
if err != nil {
|
|
1257
|
+
return fmt.Errorf("failed to load profile '%s': %w\nRun 'linkedin auth --profile %s' first", profileName, err, profileName)
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Initialize rate limiter
|
|
1261
|
+
configDir, _ := config.GetConfigDir()
|
|
1262
|
+
rateLimiter := ratelimit.New(configDir + "/ratelimit.json")
|
|
1263
|
+
rateLimiter.Load()
|
|
1264
|
+
|
|
1265
|
+
// Check rate limits
|
|
1266
|
+
ok, err := rateLimiter.CheckConnection(profileName)
|
|
1267
|
+
if err != nil {
|
|
1268
|
+
return fmt.Errorf("rate limit check failed: %w", err)
|
|
1269
|
+
}
|
|
1270
|
+
if !ok {
|
|
1271
|
+
return fmt.Errorf("rate limit exceeded: max %d connections per day", ratelimit.MaxConnectionsPerDay)
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Show stats
|
|
1275
|
+
connToday, connWeek, _ := rateLimiter.GetStats(profileName)
|
|
1276
|
+
fmt.Printf("Rate limit: %d/%d connections today, %d/%d this week\n",
|
|
1277
|
+
connToday, ratelimit.MaxConnectionsPerDay,
|
|
1278
|
+
connWeek, ratelimit.MaxConnectionsPerWeek)
|
|
1279
|
+
|
|
1280
|
+
if dryRun {
|
|
1281
|
+
fmt.Printf("[DRY-RUN] Would send connection request to %s\n", url)
|
|
1282
|
+
if message != "" {
|
|
1283
|
+
fmt.Printf("[DRY-RUN] With message: %s\n", message)
|
|
1284
|
+
}
|
|
1285
|
+
return nil
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Confirm
|
|
1289
|
+
if !yes {
|
|
1290
|
+
fmt.Printf("Send connection request to %s? (y/N): ", url)
|
|
1291
|
+
var response string
|
|
1292
|
+
fmt.Scanln(&response)
|
|
1293
|
+
if response != "y" && response != "Y" {
|
|
1294
|
+
fmt.Println("Cancelled.")
|
|
1295
|
+
return nil
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Execute
|
|
1300
|
+
client := pinchtab.NewClient(pinchtabHost)
|
|
1301
|
+
|
|
1302
|
+
fmt.Println("Starting PinchTab instance...")
|
|
1303
|
+
instance, err := client.StartInstance(profile.PinchTabProfile)
|
|
1304
|
+
if err != nil {
|
|
1305
|
+
return fmt.Errorf("failed to start PinchTab: %w", err)
|
|
1306
|
+
}
|
|
1307
|
+
defer client.StopInstance(instance.ID)
|
|
1308
|
+
|
|
1309
|
+
fmt.Printf("Navigating to %s...\n", url)
|
|
1310
|
+
if err := client.Navigate(instance.ID, url); err != nil {
|
|
1311
|
+
return fmt.Errorf("failed to navigate: %w", err)
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Wait for page to load
|
|
1315
|
+
time.Sleep(3 * time.Second)
|
|
1316
|
+
|
|
1317
|
+
// Get snapshot to find Connect button
|
|
1318
|
+
fmt.Println("Finding Connect button...")
|
|
1319
|
+
snapshot, err := client.GetSnapshot(instance.ID)
|
|
1320
|
+
if err != nil {
|
|
1321
|
+
return fmt.Errorf("failed to get snapshot: %w", err)
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Find Connect button by text
|
|
1325
|
+
connectRef := ""
|
|
1326
|
+
for _, el := range snapshot.Elements {
|
|
1327
|
+
if el.Text == linkedin.ConnectButtonText {
|
|
1328
|
+
connectRef = el.Ref
|
|
1329
|
+
break
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if connectRef == "" {
|
|
1334
|
+
return fmt.Errorf("could not find Connect button on page\nThe profile may already be connected, or the page structure changed")
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Click Connect
|
|
1338
|
+
fmt.Println("Clicking Connect button...")
|
|
1339
|
+
err = client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
|
|
1340
|
+
Kind: "humanClick",
|
|
1341
|
+
Ref: connectRef,
|
|
1342
|
+
})
|
|
1343
|
+
if err != nil {
|
|
1344
|
+
return fmt.Errorf("failed to click Connect: %w", err)
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Wait for modal
|
|
1348
|
+
time.Sleep(2 * time.Second)
|
|
1349
|
+
|
|
1350
|
+
// If message provided, add it
|
|
1351
|
+
if message != "" {
|
|
1352
|
+
// Get new snapshot for modal
|
|
1353
|
+
snapshot, err = client.GetSnapshot(instance.ID)
|
|
1354
|
+
if err != nil {
|
|
1355
|
+
return fmt.Errorf("failed to get modal snapshot: %w", err)
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Find "Add a note" button
|
|
1359
|
+
addNoteRef := ""
|
|
1360
|
+
for _, el := range snapshot.Elements {
|
|
1361
|
+
if el.Text == "Add a note" {
|
|
1362
|
+
addNoteRef = el.Ref
|
|
1363
|
+
break
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
if addNoteRef != "" {
|
|
1368
|
+
fmt.Println("Adding note...")
|
|
1369
|
+
client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
|
|
1370
|
+
Kind: "humanClick",
|
|
1371
|
+
Ref: addNoteRef,
|
|
1372
|
+
})
|
|
1373
|
+
time.Sleep(1 * time.Second)
|
|
1374
|
+
|
|
1375
|
+
// Type message
|
|
1376
|
+
// Find textarea
|
|
1377
|
+
snapshot, _ = client.GetSnapshot(instance.ID)
|
|
1378
|
+
var textareaRef string
|
|
1379
|
+
for _, el := range snapshot.Elements {
|
|
1380
|
+
if el.Type == "textbox" {
|
|
1381
|
+
textareaRef = el.Ref
|
|
1382
|
+
break
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
if textareaRef != "" {
|
|
1387
|
+
client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
|
|
1388
|
+
Kind: "humanType",
|
|
1389
|
+
Ref: textareaRef,
|
|
1390
|
+
Value: message,
|
|
1391
|
+
})
|
|
1392
|
+
time.Sleep(1 * time.Second)
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// Click Send
|
|
1398
|
+
fmt.Println("Sending connection request...")
|
|
1399
|
+
snapshot, _ = client.GetSnapshot(instance.ID)
|
|
1400
|
+
sendRef := ""
|
|
1401
|
+
for _, el := range snapshot.Elements {
|
|
1402
|
+
if el.Text == "Send" || el.Text == "Send now" {
|
|
1403
|
+
sendRef = el.Ref
|
|
1404
|
+
break
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
if sendRef == "" {
|
|
1409
|
+
return fmt.Errorf("could not find Send button")
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
err = client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
|
|
1413
|
+
Kind: "humanClick",
|
|
1414
|
+
Ref: sendRef,
|
|
1415
|
+
})
|
|
1416
|
+
if err != nil {
|
|
1417
|
+
return fmt.Errorf("failed to send request: %w", err)
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// Record success
|
|
1421
|
+
if err := rateLimiter.RecordConnection(profileName); err != nil {
|
|
1422
|
+
fmt.Fprintf(os.Stderr, "Warning: failed to record rate limit: %v\n", err)
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
fmt.Println("✓ Connection request sent successfully!")
|
|
1426
|
+
|
|
1427
|
+
return nil
|
|
1428
|
+
}
|
|
1429
|
+
```
|
|
1430
|
+
|
|
1431
|
+
**Step 2: Test build**
|
|
1432
|
+
|
|
1433
|
+
```bash
|
|
1434
|
+
go build -o linkedin-cli .
|
|
1435
|
+
./linkedin-cli connect --help
|
|
1436
|
+
```
|
|
1437
|
+
|
|
1438
|
+
**Expected:** Shows connect command help
|
|
1439
|
+
|
|
1440
|
+
**Step 3: Commit**
|
|
1441
|
+
|
|
1442
|
+
```bash
|
|
1443
|
+
git add .
|
|
1444
|
+
git commit -m "feat(cmd): add connect command with rate limiting"
|
|
1445
|
+
```
|
|
1446
|
+
|
|
1447
|
+
---
|
|
1448
|
+
|
|
1449
|
+
## Task 9: Message Command
|
|
1450
|
+
|
|
1451
|
+
**Files:**
|
|
1452
|
+
- Create: `internal/cmd/message.go`
|
|
1453
|
+
|
|
1454
|
+
**Step 1: Implement message command (similar pattern to connect)**
|
|
1455
|
+
|
|
1456
|
+
```go
|
|
1457
|
+
package cmd
|
|
1458
|
+
|
|
1459
|
+
import (
|
|
1460
|
+
"fmt"
|
|
1461
|
+
"os"
|
|
1462
|
+
"time"
|
|
1463
|
+
|
|
1464
|
+
"github.com/spf13/cobra"
|
|
1465
|
+
"github.com/thaddeus-git/linkedin-cli/internal/config"
|
|
1466
|
+
"github.com/thaddeus-git/linkedin-cli/internal/linkedin"
|
|
1467
|
+
"github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
|
|
1468
|
+
"github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
var messageCmd = &cobra.Command{
|
|
1472
|
+
Use: "message --profile NAME --url URL --message TEXT",
|
|
1473
|
+
Short: "Send a direct message",
|
|
1474
|
+
Long: `Sends a LinkedIn direct message to a connection.`,
|
|
1475
|
+
RunE: runMessage,
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
func init() {
|
|
1479
|
+
messageCmd.Flags().String("profile", "", "Profile name (required)")
|
|
1480
|
+
messageCmd.Flags().String("url", "", "LinkedIn profile URL (required)")
|
|
1481
|
+
messageCmd.Flags().String("message", "", "Message text (required)")
|
|
1482
|
+
messageCmd.Flags().Bool("dry-run", false, "Show what would be done without executing")
|
|
1483
|
+
messageCmd.Flags().Bool("yes", false, "Skip confirmation prompt")
|
|
1484
|
+
messageCmd.MarkFlagRequired("profile")
|
|
1485
|
+
messageCmd.MarkFlagRequired("url")
|
|
1486
|
+
messageCmd.MarkFlagRequired("message")
|
|
1487
|
+
rootCmd.AddCommand(messageCmd)
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
func runMessage(cmd *cobra.Command, args []string) error {
|
|
1491
|
+
profileName, _ := cmd.Flags().GetString("profile")
|
|
1492
|
+
url, _ := cmd.Flags().GetString("url")
|
|
1493
|
+
message, _ := cmd.Flags().GetString("message")
|
|
1494
|
+
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
1495
|
+
yes, _ := cmd.Flags().GetBool("yes")
|
|
1496
|
+
|
|
1497
|
+
pinchtabHost := os.Getenv("PINCHTAB_HOST")
|
|
1498
|
+
if pinchtabHost == "" {
|
|
1499
|
+
pinchtabHost = "http://localhost:9867"
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Validate
|
|
1503
|
+
if err := linkedin.ValidateProfileURL(url); err != nil {
|
|
1504
|
+
return err
|
|
1505
|
+
}
|
|
1506
|
+
url = linkedin.NormalizeProfileURL(url)
|
|
1507
|
+
|
|
1508
|
+
if len(message) > 3000 {
|
|
1509
|
+
return fmt.Errorf("message too long: max 3000 characters, got %d", len(message))
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Load profile
|
|
1513
|
+
profilePath, err := config.ProfilePath(profileName)
|
|
1514
|
+
if err != nil {
|
|
1515
|
+
return fmt.Errorf("failed to get profile path: %w", err)
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
profile, err := config.LoadProfile(profilePath)
|
|
1519
|
+
if err != nil {
|
|
1520
|
+
return fmt.Errorf("failed to load profile '%s': %w", profileName, err)
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// Rate limiting
|
|
1524
|
+
configDir, _ := config.GetConfigDir()
|
|
1525
|
+
rateLimiter := ratelimit.New(configDir + "/ratelimit.json")
|
|
1526
|
+
rateLimiter.Load()
|
|
1527
|
+
|
|
1528
|
+
ok, err := rateLimiter.CheckMessage(profileName)
|
|
1529
|
+
if err != nil {
|
|
1530
|
+
return fmt.Errorf("rate limit check failed: %w", err)
|
|
1531
|
+
}
|
|
1532
|
+
if !ok {
|
|
1533
|
+
return fmt.Errorf("rate limit exceeded: max %d messages per day", ratelimit.MaxMessagesPerDay)
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
_, _, msgToday := rateLimiter.GetStats(profileName)
|
|
1537
|
+
fmt.Printf("Rate limit: %d/%d messages today\n", msgToday, ratelimit.MaxMessagesPerDay)
|
|
1538
|
+
|
|
1539
|
+
if dryRun {
|
|
1540
|
+
fmt.Printf("[DRY-RUN] Would send message to %s\n", url)
|
|
1541
|
+
fmt.Printf("[DRY-RUN] Message: %s\n", message)
|
|
1542
|
+
return nil
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Confirm
|
|
1546
|
+
if !yes {
|
|
1547
|
+
fmt.Printf("Send message to %s? (y/N): ", url)
|
|
1548
|
+
var response string
|
|
1549
|
+
fmt.Scanln(&response)
|
|
1550
|
+
if response != "y" && response != "Y" {
|
|
1551
|
+
fmt.Println("Cancelled.")
|
|
1552
|
+
return nil
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// Execute
|
|
1557
|
+
client := pinchtab.NewClient(pinchtabHost)
|
|
1558
|
+
|
|
1559
|
+
fmt.Println("Starting PinchTab instance...")
|
|
1560
|
+
instance, err := client.StartInstance(profile.PinchTabProfile)
|
|
1561
|
+
if err != nil {
|
|
1562
|
+
return fmt.Errorf("failed to start PinchTab: %w", err)
|
|
1563
|
+
}
|
|
1564
|
+
defer client.StopInstance(instance.ID)
|
|
1565
|
+
|
|
1566
|
+
fmt.Printf("Navigating to %s...\n", url)
|
|
1567
|
+
if err := client.Navigate(instance.ID, url); err != nil {
|
|
1568
|
+
return fmt.Errorf("failed to navigate: %w", err)
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
time.Sleep(3 * time.Second)
|
|
1572
|
+
|
|
1573
|
+
// Find Message button
|
|
1574
|
+
fmt.Println("Finding Message button...")
|
|
1575
|
+
snapshot, err := client.GetSnapshot(instance.ID)
|
|
1576
|
+
if err != nil {
|
|
1577
|
+
return fmt.Errorf("failed to get snapshot: %w", err)
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
messageRef := ""
|
|
1581
|
+
for _, el := range snapshot.Elements {
|
|
1582
|
+
if el.Text == linkedin.MessageButtonText {
|
|
1583
|
+
messageRef = el.Ref
|
|
1584
|
+
break
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
if messageRef == "" {
|
|
1589
|
+
return fmt.Errorf("could not find Message button\nYou may not be connected to this profile")
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// Click Message
|
|
1593
|
+
fmt.Println("Opening message dialog...")
|
|
1594
|
+
err = client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
|
|
1595
|
+
Kind: "humanClick",
|
|
1596
|
+
Ref: messageRef,
|
|
1597
|
+
})
|
|
1598
|
+
if err != nil {
|
|
1599
|
+
return fmt.Errorf("failed to click Message: %w", err)
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
time.Sleep(2 * time.Second)
|
|
1603
|
+
|
|
1604
|
+
// Type message
|
|
1605
|
+
fmt.Println("Typing message...")
|
|
1606
|
+
snapshot, _ = client.GetSnapshot(instance.ID)
|
|
1607
|
+
var textareaRef string
|
|
1608
|
+
for _, el := range snapshot.Elements {
|
|
1609
|
+
if el.Type == "textbox" || el.Type == "textField" {
|
|
1610
|
+
textareaRef = el.Ref
|
|
1611
|
+
break
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
if textareaRef == "" {
|
|
1616
|
+
return fmt.Errorf("could not find message textarea")
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
err = client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
|
|
1620
|
+
Kind: "humanType",
|
|
1621
|
+
Ref: textareaRef,
|
|
1622
|
+
Value: message,
|
|
1623
|
+
})
|
|
1624
|
+
if err != nil {
|
|
1625
|
+
return fmt.Errorf("failed to type message: %w", err)
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
time.Sleep(1 * time.Second)
|
|
1629
|
+
|
|
1630
|
+
// Send
|
|
1631
|
+
fmt.Println("Sending message...")
|
|
1632
|
+
snapshot, _ = client.GetSnapshot(instance.ID)
|
|
1633
|
+
sendRef := ""
|
|
1634
|
+
for _, el := range snapshot.Elements {
|
|
1635
|
+
if el.Text == "Send" {
|
|
1636
|
+
sendRef = el.Ref
|
|
1637
|
+
break
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
if sendRef == "" {
|
|
1642
|
+
return fmt.Errorf("could not find Send button")
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
err = client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
|
|
1646
|
+
Kind: "humanClick",
|
|
1647
|
+
Ref: sendRef,
|
|
1648
|
+
})
|
|
1649
|
+
if err != nil {
|
|
1650
|
+
return fmt.Errorf("failed to send message: %w", err)
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Record
|
|
1654
|
+
if err := rateLimiter.RecordMessage(profileName); err != nil {
|
|
1655
|
+
fmt.Fprintf(os.Stderr, "Warning: failed to record rate limit: %v\n", err)
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
fmt.Println("✓ Message sent successfully!")
|
|
1659
|
+
|
|
1660
|
+
return nil
|
|
1661
|
+
}
|
|
1662
|
+
```
|
|
1663
|
+
|
|
1664
|
+
**Step 2: Test build**
|
|
1665
|
+
|
|
1666
|
+
```bash
|
|
1667
|
+
go build -o linkedin-cli .
|
|
1668
|
+
./linkedin-cli message --help
|
|
1669
|
+
```
|
|
1670
|
+
|
|
1671
|
+
**Step 3: Commit**
|
|
1672
|
+
|
|
1673
|
+
```bash
|
|
1674
|
+
git add .
|
|
1675
|
+
git commit -m "feat(cmd): add message command"
|
|
1676
|
+
```
|
|
1677
|
+
|
|
1678
|
+
---
|
|
1679
|
+
|
|
1680
|
+
## Task 10: Profiles List Command
|
|
1681
|
+
|
|
1682
|
+
**Files:**
|
|
1683
|
+
- Create: `internal/cmd/profiles.go`
|
|
1684
|
+
|
|
1685
|
+
**Step 1: Implement profiles command**
|
|
1686
|
+
|
|
1687
|
+
```go
|
|
1688
|
+
package cmd
|
|
1689
|
+
|
|
1690
|
+
import (
|
|
1691
|
+
"fmt"
|
|
1692
|
+
"os"
|
|
1693
|
+
"text/tabwriter"
|
|
1694
|
+
"time"
|
|
1695
|
+
|
|
1696
|
+
"github.com/spf13/cobra"
|
|
1697
|
+
"github.com/thaddeus-git/linkedin-cli/internal/config"
|
|
1698
|
+
"github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
|
|
1699
|
+
)
|
|
1700
|
+
|
|
1701
|
+
var profilesCmd = &cobra.Command{
|
|
1702
|
+
Use: "profiles",
|
|
1703
|
+
Short: "Manage LinkedIn profiles",
|
|
1704
|
+
Long: `List, show, or remove LinkedIn profiles.`,
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
var profilesListCmd = &cobra.Command{
|
|
1708
|
+
Use: "list",
|
|
1709
|
+
Short: "List all profiles",
|
|
1710
|
+
RunE: runProfilesList,
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
var profilesRemoveCmd = &cobra.Command{
|
|
1714
|
+
Use: "remove NAME",
|
|
1715
|
+
Short: "Remove a profile",
|
|
1716
|
+
Args: cobra.ExactArgs(1),
|
|
1717
|
+
RunE: runProfilesRemove,
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
func init() {
|
|
1721
|
+
profilesCmd.AddCommand(profilesListCmd)
|
|
1722
|
+
profilesCmd.AddCommand(profilesRemoveCmd)
|
|
1723
|
+
rootCmd.AddCommand(profilesCmd)
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
func runProfilesList(cmd *cobra.Command, args []string) error {
|
|
1727
|
+
profiles, err := config.ListProfiles()
|
|
1728
|
+
if err != nil {
|
|
1729
|
+
return fmt.Errorf("failed to list profiles: %w", err)
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if len(profiles) == 0 {
|
|
1733
|
+
fmt.Println("No profiles found.")
|
|
1734
|
+
fmt.Println("Create one with: linkedin auth --profile <name>")
|
|
1735
|
+
return nil
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// Load rate limits for stats
|
|
1739
|
+
configDir, _ := config.GetConfigDir()
|
|
1740
|
+
rateLimiter := ratelimit.New(configDir + "/ratelimit.json")
|
|
1741
|
+
rateLimiter.Load()
|
|
1742
|
+
|
|
1743
|
+
// Print table
|
|
1744
|
+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
1745
|
+
fmt.Fprintln(w, "PROFILE\tPINCHTAB PROFILE\tLAST USED\tCONN TODAY\tMSG TODAY")
|
|
1746
|
+
|
|
1747
|
+
for _, name := range profiles {
|
|
1748
|
+
profilePath, _ := config.ProfilePath(name)
|
|
1749
|
+
profile, err := config.LoadProfile(profilePath)
|
|
1750
|
+
if err != nil {
|
|
1751
|
+
fmt.Fprintf(w, "%s\t<error loading>\t\t\t\n", name)
|
|
1752
|
+
continue
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
lastUsed := "never"
|
|
1756
|
+
if !profile.LastUsed.IsZero() {
|
|
1757
|
+
lastUsed = profile.LastUsed.Format("2006-01-02")
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
connToday, _, msgToday := rateLimiter.GetStats(name)
|
|
1761
|
+
|
|
1762
|
+
fmt.Fprintf(w, "%s\t%s\t%s\t%d/%d\t%d/%d\n",
|
|
1763
|
+
profile.Name,
|
|
1764
|
+
profile.PinchTabProfile,
|
|
1765
|
+
lastUsed,
|
|
1766
|
+
connToday, ratelimit.MaxConnectionsPerDay,
|
|
1767
|
+
msgToday, ratelimit.MaxMessagesPerDay,
|
|
1768
|
+
)
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
w.Flush()
|
|
1772
|
+
return nil
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
func runProfilesRemove(cmd *cobra.Command, args []string) error {
|
|
1776
|
+
name := args[0]
|
|
1777
|
+
|
|
1778
|
+
exists, err := config.ProfileExists(name)
|
|
1779
|
+
if err != nil {
|
|
1780
|
+
return fmt.Errorf("failed to check profile: %w", err)
|
|
1781
|
+
}
|
|
1782
|
+
if !exists {
|
|
1783
|
+
return fmt.Errorf("profile '%s' does not exist", name)
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
fmt.Printf("Remove profile '%s'? This cannot be undone. (y/N): ", name)
|
|
1787
|
+
var response string
|
|
1788
|
+
fmt.Scanln(&response)
|
|
1789
|
+
if response != "y" && response != "Y" {
|
|
1790
|
+
fmt.Println("Cancelled.")
|
|
1791
|
+
return nil
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
if err := config.DeleteProfile(name); err != nil {
|
|
1795
|
+
return fmt.Errorf("failed to remove profile: %w", err)
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// Also remove rate limit data
|
|
1799
|
+
// Note: This is optional, data will be cleaned up on next use
|
|
1800
|
+
|
|
1801
|
+
fmt.Printf("✓ Profile '%s' removed\n", name)
|
|
1802
|
+
return nil
|
|
1803
|
+
}
|
|
1804
|
+
```
|
|
1805
|
+
|
|
1806
|
+
**Step 2: Test build**
|
|
1807
|
+
|
|
1808
|
+
```bash
|
|
1809
|
+
go build -o linkedin-cli .
|
|
1810
|
+
./linkedin-cli profiles list
|
|
1811
|
+
```
|
|
1812
|
+
|
|
1813
|
+
**Expected:** Shows empty list or existing profiles
|
|
1814
|
+
|
|
1815
|
+
**Step 3: Commit**
|
|
1816
|
+
|
|
1817
|
+
```bash
|
|
1818
|
+
git add .
|
|
1819
|
+
git commit -m "feat(cmd): add profiles list and remove commands"
|
|
1820
|
+
```
|
|
1821
|
+
|
|
1822
|
+
---
|
|
1823
|
+
|
|
1824
|
+
## Task 11: Final Integration & Build
|
|
1825
|
+
|
|
1826
|
+
**Files:**
|
|
1827
|
+
- Modify: `README.md` with complete documentation
|
|
1828
|
+
- Create: `Makefile` for convenience
|
|
1829
|
+
|
|
1830
|
+
**Step 1: Update README.md**
|
|
1831
|
+
|
|
1832
|
+
```markdown
|
|
1833
|
+
# LinkedIn CLI
|
|
1834
|
+
|
|
1835
|
+
A command-line tool for LinkedIn automation using PinchTab browser automation.
|
|
1836
|
+
|
|
1837
|
+
## Features
|
|
1838
|
+
|
|
1839
|
+
- **Authentication**: Secure profile-based authentication with session persistence
|
|
1840
|
+
- **Connection Requests**: Send connection requests with personalized notes
|
|
1841
|
+
- **Direct Messages**: Send messages to your connections
|
|
1842
|
+
- **Rate Limiting**: Built-in LinkedIn-safe rate limits (20 connections/day, 50 messages/day)
|
|
1843
|
+
- **Safety First**: Dry-run mode, confirmation prompts, and human-like delays
|
|
1844
|
+
|
|
1845
|
+
## Prerequisites
|
|
1846
|
+
|
|
1847
|
+
1. [PinchTab](https://pinchtab.com) installed and running
|
|
1848
|
+
2. Go 1.21+ (for building from source)
|
|
1849
|
+
|
|
1850
|
+
## Installation
|
|
1851
|
+
|
|
1852
|
+
```bash
|
|
1853
|
+
git clone https://github.com/thaddeus-git/linkedin-cli.git
|
|
1854
|
+
cd linkedin-cli
|
|
1855
|
+
go build -o linkedin-cli .
|
|
1856
|
+
sudo mv linkedin-cli /usr/local/bin/
|
|
1857
|
+
```
|
|
1858
|
+
|
|
1859
|
+
## Quick Start
|
|
1860
|
+
|
|
1861
|
+
### 1. Start PinchTab
|
|
1862
|
+
|
|
1863
|
+
```bash
|
|
1864
|
+
pinchtab
|
|
1865
|
+
```
|
|
1866
|
+
|
|
1867
|
+
### 2. Authenticate your LinkedIn profile
|
|
1868
|
+
|
|
1869
|
+
```bash
|
|
1870
|
+
linkedin auth --profile john
|
|
1871
|
+
```
|
|
1872
|
+
|
|
1873
|
+
This opens a browser for you to log in to LinkedIn. Your session will be saved.
|
|
1874
|
+
|
|
1875
|
+
### 3. Send a connection request
|
|
1876
|
+
|
|
1877
|
+
```bash
|
|
1878
|
+
linkedin connect --profile john \
|
|
1879
|
+
--url https://linkedin.com/in/alice \
|
|
1880
|
+
--message "Hi Alice, saw your post about..."
|
|
1881
|
+
```
|
|
1882
|
+
|
|
1883
|
+
### 4. Send a message
|
|
1884
|
+
|
|
1885
|
+
```bash
|
|
1886
|
+
linkedin message --profile john \
|
|
1887
|
+
--url https://linkedin.com/in/alice \
|
|
1888
|
+
--message "Thanks for connecting!"
|
|
1889
|
+
```
|
|
1890
|
+
|
|
1891
|
+
## Commands
|
|
1892
|
+
|
|
1893
|
+
### `auth`
|
|
1894
|
+
|
|
1895
|
+
Authenticate a new LinkedIn profile.
|
|
1896
|
+
|
|
1897
|
+
```bash
|
|
1898
|
+
linkedin auth --profile NAME
|
|
1899
|
+
```
|
|
1900
|
+
|
|
1901
|
+
### `connect`
|
|
1902
|
+
|
|
1903
|
+
Send a connection request.
|
|
1904
|
+
|
|
1905
|
+
```bash
|
|
1906
|
+
linkedin connect --profile NAME --url URL [--message TEXT] [flags]
|
|
1907
|
+
```
|
|
1908
|
+
|
|
1909
|
+
Flags:
|
|
1910
|
+
- `--profile`: Profile name (required)
|
|
1911
|
+
- `--url`: LinkedIn profile URL (required)
|
|
1912
|
+
- `--message`: Connection note (optional)
|
|
1913
|
+
- `--dry-run`: Show what would be done without executing
|
|
1914
|
+
- `--yes`: Skip confirmation prompt
|
|
1915
|
+
|
|
1916
|
+
### `message`
|
|
1917
|
+
|
|
1918
|
+
Send a direct message.
|
|
1919
|
+
|
|
1920
|
+
```bash
|
|
1921
|
+
linkedin message --profile NAME --url URL --message TEXT [flags]
|
|
1922
|
+
```
|
|
1923
|
+
|
|
1924
|
+
Flags:
|
|
1925
|
+
- `--profile`: Profile name (required)
|
|
1926
|
+
- `--url`: LinkedIn profile URL (required)
|
|
1927
|
+
- `--message`: Message text (required)
|
|
1928
|
+
- `--dry-run`: Show what would be done without executing
|
|
1929
|
+
- `--yes`: Skip confirmation prompt
|
|
1930
|
+
|
|
1931
|
+
### `profiles`
|
|
1932
|
+
|
|
1933
|
+
Manage profiles.
|
|
1934
|
+
|
|
1935
|
+
```bash
|
|
1936
|
+
# List all profiles
|
|
1937
|
+
linkedin profiles list
|
|
1938
|
+
|
|
1939
|
+
# Remove a profile
|
|
1940
|
+
linkedin profiles remove NAME
|
|
1941
|
+
```
|
|
1942
|
+
|
|
1943
|
+
## Configuration
|
|
1944
|
+
|
|
1945
|
+
Configuration is stored in `~/.linkedin-cli/`:
|
|
1946
|
+
|
|
1947
|
+
- `profiles/`: Profile configurations
|
|
1948
|
+
- `ratelimit.json`: Rate limiting state
|
|
1949
|
+
|
|
1950
|
+
## Environment Variables
|
|
1951
|
+
|
|
1952
|
+
- `PINCHTAB_HOST`: PinchTab server URL (default: `http://localhost:9867`)
|
|
1953
|
+
- `LINKEDIN_PROFILE`: Default profile name
|
|
1954
|
+
|
|
1955
|
+
## Rate Limits
|
|
1956
|
+
|
|
1957
|
+
Built-in LinkedIn-safe limits:
|
|
1958
|
+
|
|
1959
|
+
- **Connection requests**: 20 per day, 100 per week
|
|
1960
|
+
- **Messages**: 50 per day
|
|
1961
|
+
|
|
1962
|
+
These limits reset automatically. The tool tracks usage per profile.
|
|
1963
|
+
|
|
1964
|
+
## Safety Features
|
|
1965
|
+
|
|
1966
|
+
1. **Dry-run mode**: Preview actions before executing
|
|
1967
|
+
2. **Confirmation prompts**: Confirm destructive actions
|
|
1968
|
+
3. **Rate limiting**: Prevents exceeding LinkedIn limits
|
|
1969
|
+
4. **Human-like delays**: Random delays between actions
|
|
1970
|
+
5. **Circuit breaker**: Stops after consecutive failures
|
|
1971
|
+
|
|
1972
|
+
## Development
|
|
1973
|
+
|
|
1974
|
+
```bash
|
|
1975
|
+
# Run tests
|
|
1976
|
+
go test ./...
|
|
1977
|
+
|
|
1978
|
+
# Build
|
|
1979
|
+
go build -o linkedin-cli .
|
|
1980
|
+
|
|
1981
|
+
# Install locally
|
|
1982
|
+
go install
|
|
1983
|
+
```
|
|
1984
|
+
|
|
1985
|
+
## License
|
|
1986
|
+
|
|
1987
|
+
MIT
|
|
1988
|
+
```
|
|
1989
|
+
|
|
1990
|
+
**Step 2: Create Makefile**
|
|
1991
|
+
|
|
1992
|
+
```makefile
|
|
1993
|
+
.PHONY: build test clean install lint
|
|
1994
|
+
|
|
1995
|
+
BINARY_NAME=linkedin-cli
|
|
1996
|
+
BUILD_DIR=.
|
|
1997
|
+
|
|
1998
|
+
build:
|
|
1999
|
+
go build -o $(BUILD_DIR)/$(BINARY_NAME) .
|
|
2000
|
+
|
|
2001
|
+
test:
|
|
2002
|
+
go test -v ./...
|
|
2003
|
+
|
|
2004
|
+
clean:
|
|
2005
|
+
rm -f $(BUILD_DIR)/$(BINARY_NAME)
|
|
2006
|
+
|
|
2007
|
+
install: build
|
|
2008
|
+
sudo cp $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/
|
|
2009
|
+
|
|
2010
|
+
lint:
|
|
2011
|
+
golangci-lint run
|
|
2012
|
+
|
|
2013
|
+
dev:
|
|
2014
|
+
go run . $(ARGS)
|
|
2015
|
+
```
|
|
2016
|
+
|
|
2017
|
+
**Step 3: Final build and test**
|
|
2018
|
+
|
|
2019
|
+
```bash
|
|
2020
|
+
make build
|
|
2021
|
+
./linkedin-cli --help
|
|
2022
|
+
./linkedin-cli auth --help
|
|
2023
|
+
./linkedin-cli connect --help
|
|
2024
|
+
./linkedin-cli message --help
|
|
2025
|
+
./linkedin-cli profiles --help
|
|
2026
|
+
```
|
|
2027
|
+
|
|
2028
|
+
**Step 4: Final commit**
|
|
2029
|
+
|
|
2030
|
+
```bash
|
|
2031
|
+
git add .
|
|
2032
|
+
git commit -m "docs: add README and Makefile"
|
|
2033
|
+
```
|
|
2034
|
+
|
|
2035
|
+
---
|
|
2036
|
+
|
|
2037
|
+
## Milestone 1 Complete
|
|
2038
|
+
|
|
2039
|
+
The LinkedIn CLI is now ready for personal use. It supports:
|
|
2040
|
+
|
|
2041
|
+
- [x] Profile authentication with PinchTab
|
|
2042
|
+
- [x] Connection requests with notes
|
|
2043
|
+
- [x] Direct messaging
|
|
2044
|
+
- [x] Rate limiting (20 conn/day, 50 msg/day)
|
|
2045
|
+
- [x] Dry-run mode
|
|
2046
|
+
- [x] Profile management
|
|
2047
|
+
- [x] Safety confirmations
|
|
2048
|
+
|
|
2049
|
+
## Next Steps (Future Milestones)
|
|
2050
|
+
|
|
2051
|
+
- **Batch processing**: `linkedin queue` command for CSV import
|
|
2052
|
+
- **Multi-user support**: Team management with role-based access
|
|
2053
|
+
- **Scheduling**: Time-based campaign execution
|
|
2054
|
+
- **Templates**: Message templates with variables
|
|
2055
|
+
- **Analytics**: Track acceptance rates, response rates
|
|
2056
|
+
- **Web UI**: Browser-based management interface
|
|
2057
|
+
- **API server**: HTTP API for external integrations
|
|
2058
|
+
|
|
2059
|
+
## Troubleshooting
|
|
2060
|
+
|
|
2061
|
+
### "PinchTab not running"
|
|
2062
|
+
|
|
2063
|
+
Start PinchTab first:
|
|
2064
|
+
```bash
|
|
2065
|
+
pinchtab
|
|
2066
|
+
```
|
|
2067
|
+
|
|
2068
|
+
### "Rate limit exceeded"
|
|
2069
|
+
|
|
2070
|
+
Wait for the daily/weekly limit to reset. Check usage with:
|
|
2071
|
+
```bash
|
|
2072
|
+
linkedin profiles list
|
|
2073
|
+
```
|
|
2074
|
+
|
|
2075
|
+
### "Could not find Connect button"
|
|
2076
|
+
|
|
2077
|
+
LinkedIn may have changed their UI. Try:
|
|
2078
|
+
1. Update to latest version
|
|
2079
|
+
2. Check if you're already connected
|
|
2080
|
+
3. Use `--dry-run` to debug
|
|
2081
|
+
|
|
2082
|
+
### Profile not found
|
|
2083
|
+
|
|
2084
|
+
Create the profile first:
|
|
2085
|
+
```bash
|
|
2086
|
+
linkedin auth --profile <name>
|
|
2087
|
+
```
|