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,2420 @@
|
|
|
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.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Go CLI using Cobra framework with packages for PinchTab HTTP client, LinkedIn-specific selectors, rate limiting, and configuration management. State stored in JSON files.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Go 1.21+, Cobra (CLI), standard library HTTP client
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Prerequisites
|
|
14
|
+
|
|
15
|
+
- Go 1.21+ installed
|
|
16
|
+
- PinchTab installed and running (`pinchtab` command available)
|
|
17
|
+
- Git initialized in project directory
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Task 1: Project Setup
|
|
22
|
+
|
|
23
|
+
**Files:**
|
|
24
|
+
- Create: `go.mod`
|
|
25
|
+
- Create: `go.sum` (generated)
|
|
26
|
+
- Create: `.gitignore`
|
|
27
|
+
- Create: `README.md`
|
|
28
|
+
|
|
29
|
+
**Step 1: Initialize Go module**
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cd /Users/thaddeus/projects/linkedin-cli
|
|
33
|
+
go mod init github.com/thaddeus-git/linkedin-cli
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Step 2: Create .gitignore**
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cat > .gitignore << 'EOF'
|
|
40
|
+
# Binaries
|
|
41
|
+
linkedin-cli
|
|
42
|
+
*.exe
|
|
43
|
+
|
|
44
|
+
# Go
|
|
45
|
+
vendor/
|
|
46
|
+
*.test
|
|
47
|
+
*.out
|
|
48
|
+
|
|
49
|
+
# Project
|
|
50
|
+
.linkedin-cli/
|
|
51
|
+
*.log
|
|
52
|
+
|
|
53
|
+
# IDE
|
|
54
|
+
.vscode/
|
|
55
|
+
.idea/
|
|
56
|
+
*.swp
|
|
57
|
+
*.swo
|
|
58
|
+
EOF
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Step 3: Create directory structure**
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
mkdir -p cmd/linkedin
|
|
65
|
+
mkdir -p internal/cmd
|
|
66
|
+
mkdir -p internal/config
|
|
67
|
+
mkdir -p internal/pinchtab
|
|
68
|
+
mkdir -p internal/linkedin
|
|
69
|
+
mkdir -p internal/ratelimit
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Step 4: Initial README**
|
|
73
|
+
|
|
74
|
+
Create: `README.md`
|
|
75
|
+
|
|
76
|
+
```markdown
|
|
77
|
+
# LinkedIn CLI
|
|
78
|
+
|
|
79
|
+
A CLI tool for LinkedIn automation using PinchTab.
|
|
80
|
+
|
|
81
|
+
## Prerequisites
|
|
82
|
+
|
|
83
|
+
- Go 1.21+
|
|
84
|
+
- PinchTab installed: `curl -fsSL https://pinchtab.com/install.sh | bash`
|
|
85
|
+
|
|
86
|
+
## Installation
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
go install github.com/thaddeus-git/linkedin-cli/cmd/linkedin@latest
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Quick Start
|
|
93
|
+
|
|
94
|
+
1. Start PinchTab:
|
|
95
|
+
```bash
|
|
96
|
+
pinchtab
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
2. Authenticate your LinkedIn account:
|
|
100
|
+
```bash
|
|
101
|
+
linkedin auth --profile john
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
3. Send a connection request:
|
|
105
|
+
```bash
|
|
106
|
+
linkedin connect --profile john --url linkedin.com/in/alice
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Commands
|
|
110
|
+
|
|
111
|
+
- `linkedin auth` - Authenticate a LinkedIn profile
|
|
112
|
+
- `linkedin connect` - Send connection requests
|
|
113
|
+
- `linkedin message` - Send direct messages
|
|
114
|
+
- `linkedin queue` - Process batch requests from file
|
|
115
|
+
|
|
116
|
+
## Configuration
|
|
117
|
+
|
|
118
|
+
Profiles and rate limits are stored in `~/.linkedin-cli/`.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Step 5: Commit**
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
git add go.mod .gitignore README.md
|
|
129
|
+
git commit -m "chore: initialize project structure"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Task 2: PinchTab HTTP Client (Core)
|
|
135
|
+
|
|
136
|
+
**Files:**
|
|
137
|
+
- Create: `internal/pinchtab/types.go`
|
|
138
|
+
- Create: `internal/pinchtab/client.go`
|
|
139
|
+
- Test: `internal/pinchtab/client_test.go`
|
|
140
|
+
|
|
141
|
+
**Step 1: Create types**
|
|
142
|
+
|
|
143
|
+
Create: `internal/pinchtab/types.go`
|
|
144
|
+
|
|
145
|
+
```go
|
|
146
|
+
package pinchtab
|
|
147
|
+
|
|
148
|
+
// Instance represents a PinchTab browser instance
|
|
149
|
+
type Instance struct {
|
|
150
|
+
ID string `json:"id"`
|
|
151
|
+
ProfileID string `json:"profileId"`
|
|
152
|
+
Port int `json:"port"`
|
|
153
|
+
Mode string `json:"mode"`
|
|
154
|
+
Status string `json:"status"`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Tab represents a browser tab
|
|
158
|
+
type Tab struct {
|
|
159
|
+
ID string `json:"id"`
|
|
160
|
+
InstanceID string `json:"instanceId"`
|
|
161
|
+
URL string `json:"url"`
|
|
162
|
+
Title string `json:"title"`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// NavigateRequest represents a navigation action
|
|
166
|
+
type NavigateRequest struct {
|
|
167
|
+
URL string `json:"url"`
|
|
168
|
+
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
|
169
|
+
BlockImages bool `json:"blockImages,omitempty"`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ActionRequest represents a browser action
|
|
173
|
+
type ActionRequest struct {
|
|
174
|
+
Kind string `json:"kind"`
|
|
175
|
+
Ref string `json:"ref,omitempty"`
|
|
176
|
+
Selector string `json:"selector,omitempty"`
|
|
177
|
+
Text string `json:"text,omitempty"`
|
|
178
|
+
Value interface{} `json:"value,omitempty"`
|
|
179
|
+
Key string `json:"key,omitempty"`
|
|
180
|
+
Direction string `json:"direction,omitempty"`
|
|
181
|
+
Amount int `json:"amount,omitempty"`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Snapshot represents the page accessibility tree
|
|
185
|
+
type Snapshot struct {
|
|
186
|
+
URL string `json:"url"`
|
|
187
|
+
Title string `json:"title"`
|
|
188
|
+
Elements []Element `json:"elements"`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Element represents an interactive element
|
|
192
|
+
type Element struct {
|
|
193
|
+
Ref string `json:"ref"`
|
|
194
|
+
Type string `json:"type"`
|
|
195
|
+
Role string `json:"role"`
|
|
196
|
+
Name string `json:"name"`
|
|
197
|
+
Visible bool `json:"visible"`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// TextResponse represents extracted text
|
|
201
|
+
type TextResponse struct {
|
|
202
|
+
Text string `json:"text"`
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Step 2: Create client**
|
|
207
|
+
|
|
208
|
+
Create: `internal/pinchtab/client.go`
|
|
209
|
+
|
|
210
|
+
```go
|
|
211
|
+
package pinchtab
|
|
212
|
+
|
|
213
|
+
import (
|
|
214
|
+
"bytes"
|
|
215
|
+
"encoding/json"
|
|
216
|
+
"fmt"
|
|
217
|
+
"io"
|
|
218
|
+
"net/http"
|
|
219
|
+
"time"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
// Client wraps the PinchTab HTTP API
|
|
223
|
+
type Client struct {
|
|
224
|
+
baseURL string
|
|
225
|
+
client *http.Client
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// NewClient creates a new PinchTab client
|
|
229
|
+
func NewClient(baseURL string) *Client {
|
|
230
|
+
if baseURL == "" {
|
|
231
|
+
baseURL = "http://localhost:9867"
|
|
232
|
+
}
|
|
233
|
+
return &Client{
|
|
234
|
+
baseURL: baseURL,
|
|
235
|
+
client: &http.Client{
|
|
236
|
+
Timeout: 30 * time.Second,
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// StartInstance starts a new browser instance
|
|
242
|
+
func (c *Client) StartInstance(profileID string) (*Instance, error) {
|
|
243
|
+
reqBody := map[string]string{
|
|
244
|
+
"profileId": profileID,
|
|
245
|
+
"mode": "headed",
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
var instance Instance
|
|
249
|
+
err := c.post("/instances/start", reqBody, &instance)
|
|
250
|
+
if err != nil {
|
|
251
|
+
return nil, fmt.Errorf("failed to start instance: %w", err)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return &instance, nil
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// StopInstance stops a browser instance
|
|
258
|
+
func (c *Client) StopInstance(instanceID string) error {
|
|
259
|
+
return c.post(fmt.Sprintf("/instances/%s/stop", instanceID), nil, nil)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// NewTab creates a new tab in an instance
|
|
263
|
+
func (c *Client) NewTab(instanceID string, url string) (*Tab, error) {
|
|
264
|
+
reqBody := map[string]string{
|
|
265
|
+
"url": url,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
var tab Tab
|
|
269
|
+
err := c.post(fmt.Sprintf("/instances/%s/tabs/open", instanceID), reqBody, &tab)
|
|
270
|
+
if err != nil {
|
|
271
|
+
return nil, fmt.Errorf("failed to create tab: %w", err)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return &tab, nil
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Navigate navigates a tab to a URL
|
|
278
|
+
func (c *Client) Navigate(tabID string, req NavigateRequest) error {
|
|
279
|
+
return c.post(fmt.Sprintf("/tabs/%s/navigate", tabID), req, nil)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// GetSnapshot gets the accessibility tree snapshot
|
|
283
|
+
func (c *Client) GetSnapshot(tabID string, filter string) (*Snapshot, error) {
|
|
284
|
+
url := fmt.Sprintf("/tabs/%s/snapshot", tabID)
|
|
285
|
+
if filter != "" {
|
|
286
|
+
url += "?filter=" + filter
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
var snapshot Snapshot
|
|
290
|
+
err := c.get(url, &snapshot)
|
|
291
|
+
if err != nil {
|
|
292
|
+
return nil, fmt.Errorf("failed to get snapshot: %w", err)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return &snapshot, nil
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// GetText extracts text from a tab
|
|
299
|
+
func (c *Client) GetText(tabID string) (*TextResponse, error) {
|
|
300
|
+
var resp TextResponse
|
|
301
|
+
err := c.get(fmt.Sprintf("/tabs/%s/text", tabID), &resp)
|
|
302
|
+
if err != nil {
|
|
303
|
+
return nil, fmt.Errorf("failed to get text: %w", err)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return &resp, nil
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Click clicks an element
|
|
310
|
+
func (c *Client) Click(tabID string, ref string) error {
|
|
311
|
+
req := ActionRequest{
|
|
312
|
+
Kind: "click",
|
|
313
|
+
Ref: ref,
|
|
314
|
+
}
|
|
315
|
+
return c.post(fmt.Sprintf("/tabs/%s/actions", tabID), []ActionRequest{req}, nil)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// HumanClick clicks with human-like randomization
|
|
319
|
+
func (c *Client) HumanClick(tabID string, ref string) error {
|
|
320
|
+
req := ActionRequest{
|
|
321
|
+
Kind: "humanClick",
|
|
322
|
+
Ref: ref,
|
|
323
|
+
}
|
|
324
|
+
return c.post(fmt.Sprintf("/tabs/%s/actions", tabID), []ActionRequest{req}, nil)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Fill fills an input field
|
|
328
|
+
func (c *Client) Fill(tabID string, ref string, text string) error {
|
|
329
|
+
req := ActionRequest{
|
|
330
|
+
Kind: "fill",
|
|
331
|
+
Ref: ref,
|
|
332
|
+
Text: text,
|
|
333
|
+
}
|
|
334
|
+
return c.post(fmt.Sprintf("/tabs/%s/actions", tabID), []ActionRequest{req}, nil)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// HumanType types with human-like delays
|
|
338
|
+
func (c *Client) HumanType(tabID string, ref string, text string) error {
|
|
339
|
+
req := ActionRequest{
|
|
340
|
+
Kind: "type",
|
|
341
|
+
Ref: ref,
|
|
342
|
+
Text: text,
|
|
343
|
+
}
|
|
344
|
+
return c.post(fmt.Sprintf("/tabs/%s/actions", tabID), []ActionRequest{req}, nil)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Execute runs JavaScript in the tab
|
|
348
|
+
func (c *Client) Execute(tabID string, script string) (interface{}, error) {
|
|
349
|
+
req := map[string]string{
|
|
350
|
+
"script": script,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
var result interface{}
|
|
354
|
+
err := c.post(fmt.Sprintf("/tabs/%s/evaluate", tabID), req, &result)
|
|
355
|
+
if err != nil {
|
|
356
|
+
return nil, fmt.Errorf("failed to execute script: %w", err)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return result, nil
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// post makes a POST request
|
|
363
|
+
func (c *Client) post(path string, body interface{}, result interface{}) error {
|
|
364
|
+
var bodyReader io.Reader
|
|
365
|
+
if body != nil {
|
|
366
|
+
jsonBody, err := json.Marshal(body)
|
|
367
|
+
if err != nil {
|
|
368
|
+
return fmt.Errorf("failed to marshal request: %w", err)
|
|
369
|
+
}
|
|
370
|
+
bodyReader = bytes.NewReader(jsonBody)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
req, err := http.NewRequest("POST", c.baseURL+path, bodyReader)
|
|
374
|
+
if err != nil {
|
|
375
|
+
return fmt.Errorf("failed to create request: %w", err)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if body != nil {
|
|
379
|
+
req.Header.Set("Content-Type", "application/json")
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
resp, err := c.client.Do(req)
|
|
383
|
+
if err != nil {
|
|
384
|
+
return fmt.Errorf("request failed: %w", err)
|
|
385
|
+
}
|
|
386
|
+
defer resp.Body.Close()
|
|
387
|
+
|
|
388
|
+
if resp.StatusCode >= 400 {
|
|
389
|
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
390
|
+
return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if result != nil {
|
|
394
|
+
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
|
395
|
+
return fmt.Errorf("failed to decode response: %w", err)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return nil
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// get makes a GET request
|
|
403
|
+
func (c *Client) get(path string, result interface{}) error {
|
|
404
|
+
resp, err := c.client.Get(c.baseURL + path)
|
|
405
|
+
if err != nil {
|
|
406
|
+
return fmt.Errorf("request failed: %w", err)
|
|
407
|
+
}
|
|
408
|
+
defer resp.Body.Close()
|
|
409
|
+
|
|
410
|
+
if resp.StatusCode >= 400 {
|
|
411
|
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
412
|
+
return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
|
416
|
+
return fmt.Errorf("failed to decode response: %w", err)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return nil
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
**Step 3: Write test**
|
|
424
|
+
|
|
425
|
+
Create: `internal/pinchtab/client_test.go`
|
|
426
|
+
|
|
427
|
+
```go
|
|
428
|
+
package pinchtab
|
|
429
|
+
|
|
430
|
+
import (
|
|
431
|
+
"encoding/json"
|
|
432
|
+
"net/http"
|
|
433
|
+
"net/http/httptest"
|
|
434
|
+
"testing"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
func TestNewClient(t *testing.T) {
|
|
438
|
+
client := NewClient("")
|
|
439
|
+
if client.baseURL != "http://localhost:9867" {
|
|
440
|
+
t.Errorf("expected default URL, got %s", client.baseURL)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
client = NewClient("http://custom:8080")
|
|
444
|
+
if client.baseURL != "http://custom:8080" {
|
|
445
|
+
t.Errorf("expected custom URL, got %s", client.baseURL)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
func TestGetSnapshot(t *testing.T) {
|
|
450
|
+
// Mock server
|
|
451
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
452
|
+
if r.URL.Path != "/tabs/test-tab/snapshot" {
|
|
453
|
+
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
snapshot := Snapshot{
|
|
457
|
+
URL: "https://linkedin.com",
|
|
458
|
+
Title: "LinkedIn",
|
|
459
|
+
Elements: []Element{
|
|
460
|
+
{Ref: "e0", Type: "button", Name: "Connect"},
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
json.NewEncoder(w).Encode(snapshot)
|
|
464
|
+
}))
|
|
465
|
+
defer server.Close()
|
|
466
|
+
|
|
467
|
+
client := NewClient(server.URL)
|
|
468
|
+
snapshot, err := client.GetSnapshot("test-tab", "")
|
|
469
|
+
if err != nil {
|
|
470
|
+
t.Fatalf("unexpected error: %v", err)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if snapshot.Title != "LinkedIn" {
|
|
474
|
+
t.Errorf("expected title 'LinkedIn', got %s", snapshot.Title)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if len(snapshot.Elements) != 1 {
|
|
478
|
+
t.Errorf("expected 1 element, got %d", len(snapshot.Elements))
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
func TestGetSnapshot_Error(t *testing.T) {
|
|
483
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
484
|
+
w.WriteHeader(http.StatusInternalServerError)
|
|
485
|
+
w.Write([]byte(`{"error": "tab not found"}`))
|
|
486
|
+
}))
|
|
487
|
+
defer server.Close()
|
|
488
|
+
|
|
489
|
+
client := NewClient(server.URL)
|
|
490
|
+
_, err := client.GetSnapshot("invalid-tab", "")
|
|
491
|
+
if err == nil {
|
|
492
|
+
t.Error("expected error, got nil")
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
**Step 4: Run tests**
|
|
498
|
+
|
|
499
|
+
```bash
|
|
500
|
+
cd /Users/thaddeus/projects/linkedin-cli
|
|
501
|
+
go test ./internal/pinchtab/... -v
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Expected: 3 tests PASS
|
|
505
|
+
|
|
506
|
+
**Step 5: Commit**
|
|
507
|
+
|
|
508
|
+
```bash
|
|
509
|
+
git add internal/pinchtab/
|
|
510
|
+
git commit -m "feat: add PinchTab HTTP client"
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## Task 3: Configuration Management
|
|
516
|
+
|
|
517
|
+
**Files:**
|
|
518
|
+
- Create: `internal/config/config.go`
|
|
519
|
+
- Create: `internal/config/profile.go`
|
|
520
|
+
- Test: `internal/config/config_test.go`
|
|
521
|
+
|
|
522
|
+
**Step 1: Create config types**
|
|
523
|
+
|
|
524
|
+
Create: `internal/config/profile.go`
|
|
525
|
+
|
|
526
|
+
```go
|
|
527
|
+
package config
|
|
528
|
+
|
|
529
|
+
import "time"
|
|
530
|
+
|
|
531
|
+
// Profile represents a LinkedIn profile configuration
|
|
532
|
+
type Profile struct {
|
|
533
|
+
Name string `json:"name"`
|
|
534
|
+
PinchTabProfile string `json:"pinchtab_profile"`
|
|
535
|
+
CreatedAt time.Time `json:"created_at"`
|
|
536
|
+
LastUsed time.Time `json:"last_used"`
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// RateLimits tracks rate limit state per profile
|
|
540
|
+
type RateLimits struct {
|
|
541
|
+
Connections RateLimit `json:"connections"`
|
|
542
|
+
Messages RateLimit `json:"messages"`
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// RateLimit tracks a single rate limit
|
|
546
|
+
type RateLimit struct {
|
|
547
|
+
Today int `json:"today"`
|
|
548
|
+
ThisWeek int `json:"this_week"`
|
|
549
|
+
LastAction time.Time `json:"last_action"`
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// CanConnect checks if connection request is allowed
|
|
553
|
+
func (r *RateLimits) CanConnect(dailyLimit, weeklyLimit int) bool {
|
|
554
|
+
return r.Connections.Today < dailyLimit && r.Connections.ThisWeek < weeklyLimit
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// CanMessage checks if message is allowed
|
|
558
|
+
func (r *RateLimits) CanMessage(dailyLimit int) bool {
|
|
559
|
+
return r.Messages.Today < dailyLimit
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// RecordConnection records a connection request
|
|
563
|
+
func (r *RateLimits) RecordConnection() {
|
|
564
|
+
r.Connections.Today++
|
|
565
|
+
r.Connections.ThisWeek++
|
|
566
|
+
r.Connections.LastAction = time.Now()
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// RecordMessage records a message sent
|
|
570
|
+
func (r *RateLimits) RecordMessage() {
|
|
571
|
+
r.Messages.Today++
|
|
572
|
+
r.Messages.LastAction = time.Now()
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ResetIfNeeded resets daily/weekly counters if day/week has changed
|
|
576
|
+
func (r *RateLimits) ResetIfNeeded() {
|
|
577
|
+
now := time.Now()
|
|
578
|
+
last := r.Connections.LastAction
|
|
579
|
+
|
|
580
|
+
// Reset daily if different day
|
|
581
|
+
if last.Year() != now.Year() || last.YearDay() != now.YearDay() {
|
|
582
|
+
r.Connections.Today = 0
|
|
583
|
+
r.Messages.Today = 0
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Reset weekly if different week
|
|
587
|
+
_, lastWeek := last.ISOWeek()
|
|
588
|
+
_, nowWeek := now.ISOWeek()
|
|
589
|
+
if lastWeek != nowWeek {
|
|
590
|
+
r.Connections.ThisWeek = 0
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
**Step 2: Create config manager**
|
|
596
|
+
|
|
597
|
+
Create: `internal/config/config.go`
|
|
598
|
+
|
|
599
|
+
```go
|
|
600
|
+
package config
|
|
601
|
+
|
|
602
|
+
import (
|
|
603
|
+
"encoding/json"
|
|
604
|
+
"fmt"
|
|
605
|
+
"os"
|
|
606
|
+
"path/filepath"
|
|
607
|
+
"time"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
// Manager handles configuration and state
|
|
611
|
+
type Manager struct {
|
|
612
|
+
configDir string
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// NewManager creates a new config manager
|
|
616
|
+
func NewManager() (*Manager, error) {
|
|
617
|
+
home, err := os.UserHomeDir()
|
|
618
|
+
if err != nil {
|
|
619
|
+
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
configDir := filepath.Join(home, ".linkedin-cli")
|
|
623
|
+
if err := os.MkdirAll(configDir, 0755); err != nil {
|
|
624
|
+
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return &Manager{configDir: configDir}, nil
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// GetConfigDir returns the config directory path
|
|
631
|
+
func (m *Manager) GetConfigDir() string {
|
|
632
|
+
return m.configDir
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ProfileExists checks if a profile exists
|
|
636
|
+
func (m *Manager) ProfileExists(name string) bool {
|
|
637
|
+
path := filepath.Join(m.configDir, "profiles", name+".json")
|
|
638
|
+
_, err := os.Stat(path)
|
|
639
|
+
return err == nil
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// LoadProfile loads a profile
|
|
643
|
+
func (m *Manager) LoadProfile(name string) (*Profile, error) {
|
|
644
|
+
path := filepath.Join(m.configDir, "profiles", name+".json")
|
|
645
|
+
|
|
646
|
+
data, err := os.ReadFile(path)
|
|
647
|
+
if err != nil {
|
|
648
|
+
if os.IsNotExist(err) {
|
|
649
|
+
return nil, fmt.Errorf("profile '%s' not found", name)
|
|
650
|
+
}
|
|
651
|
+
return nil, fmt.Errorf("failed to read profile: %w", err)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
var profile Profile
|
|
655
|
+
if err := json.Unmarshal(data, &profile); err != nil {
|
|
656
|
+
return nil, fmt.Errorf("failed to parse profile: %w", err)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return &profile, nil
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// SaveProfile saves a profile
|
|
663
|
+
func (m *Manager) SaveProfile(profile *Profile) error {
|
|
664
|
+
profilesDir := filepath.Join(m.configDir, "profiles")
|
|
665
|
+
if err := os.MkdirAll(profilesDir, 0755); err != nil {
|
|
666
|
+
return fmt.Errorf("failed to create profiles directory: %w", err)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
path := filepath.Join(profilesDir, profile.Name+".json")
|
|
670
|
+
|
|
671
|
+
data, err := json.MarshalIndent(profile, "", " ")
|
|
672
|
+
if err != nil {
|
|
673
|
+
return fmt.Errorf("failed to marshal profile: %w", err)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if err := os.WriteFile(path, data, 0644); err != nil {
|
|
677
|
+
return fmt.Errorf("failed to write profile: %w", err)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return nil
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ListProfiles lists all profiles
|
|
684
|
+
func (m *Manager) ListProfiles() ([]string, error) {
|
|
685
|
+
profilesDir := filepath.Join(m.configDir, "profiles")
|
|
686
|
+
|
|
687
|
+
entries, err := os.ReadDir(profilesDir)
|
|
688
|
+
if err != nil {
|
|
689
|
+
if os.IsNotExist(err) {
|
|
690
|
+
return []string{}, nil
|
|
691
|
+
}
|
|
692
|
+
return nil, fmt.Errorf("failed to read profiles directory: %w", err)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
var profiles []string
|
|
696
|
+
for _, entry := range entries {
|
|
697
|
+
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
|
|
698
|
+
name := entry.Name()[:len(entry.Name())-5] // Remove .json
|
|
699
|
+
profiles = append(profiles, name)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return profiles, nil
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// DeleteProfile deletes a profile
|
|
707
|
+
func (m *Manager) DeleteProfile(name string) error {
|
|
708
|
+
path := filepath.Join(m.configDir, "profiles", name+".json")
|
|
709
|
+
if err := os.Remove(path); err != nil {
|
|
710
|
+
if os.IsNotExist(err) {
|
|
711
|
+
return fmt.Errorf("profile '%s' not found", name)
|
|
712
|
+
}
|
|
713
|
+
return fmt.Errorf("failed to delete profile: %w", err)
|
|
714
|
+
}
|
|
715
|
+
return nil
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// LoadRateLimits loads rate limits for a profile
|
|
719
|
+
func (m *Manager) LoadRateLimits(profileName string) (*RateLimits, error) {
|
|
720
|
+
path := filepath.Join(m.configDir, "ratelimit.json")
|
|
721
|
+
|
|
722
|
+
data, err := os.ReadFile(path)
|
|
723
|
+
if err != nil {
|
|
724
|
+
if os.IsNotExist(err) {
|
|
725
|
+
return &RateLimits{}, nil
|
|
726
|
+
}
|
|
727
|
+
return nil, fmt.Errorf("failed to read rate limits: %w", err)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
var allLimits map[string]*RateLimits
|
|
731
|
+
if err := json.Unmarshal(data, &allLimits); err != nil {
|
|
732
|
+
return nil, fmt.Errorf("failed to parse rate limits: %w", err)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
limits, exists := allLimits[profileName]
|
|
736
|
+
if !exists {
|
|
737
|
+
return &RateLimits{}, nil
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Reset counters if needed
|
|
741
|
+
limits.ResetIfNeeded()
|
|
742
|
+
|
|
743
|
+
return limits, nil
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// SaveRateLimits saves rate limits for a profile
|
|
747
|
+
func (m *Manager) SaveRateLimits(profileName string, limits *RateLimits) error {
|
|
748
|
+
path := filepath.Join(m.configDir, "ratelimit.json")
|
|
749
|
+
|
|
750
|
+
var allLimits map[string]*RateLimits
|
|
751
|
+
|
|
752
|
+
// Load existing
|
|
753
|
+
data, err := os.ReadFile(path)
|
|
754
|
+
if err == nil {
|
|
755
|
+
json.Unmarshal(data, &allLimits)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if allLimits == nil {
|
|
759
|
+
allLimits = make(map[string]*RateLimits)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
allLimits[profileName] = limits
|
|
763
|
+
|
|
764
|
+
// Save all
|
|
765
|
+
data, err = json.MarshalIndent(allLimits, "", " ")
|
|
766
|
+
if err != nil {
|
|
767
|
+
return fmt.Errorf("failed to marshal rate limits: %w", err)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if err := os.WriteFile(path, data, 0644); err != nil {
|
|
771
|
+
return fmt.Errorf("failed to write rate limits: %w", err)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return nil
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// UpdateLastUsed updates the last used timestamp for a profile
|
|
778
|
+
func (m *Manager) UpdateLastUsed(profileName string) error {
|
|
779
|
+
profile, err := m.LoadProfile(profileName)
|
|
780
|
+
if err != nil {
|
|
781
|
+
return err
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
profile.LastUsed = time.Now()
|
|
785
|
+
return m.SaveProfile(profile)
|
|
786
|
+
}
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
**Step 3: Write tests**
|
|
790
|
+
|
|
791
|
+
Create: `internal/config/config_test.go`
|
|
792
|
+
|
|
793
|
+
```go
|
|
794
|
+
package config
|
|
795
|
+
|
|
796
|
+
import (
|
|
797
|
+
"os"
|
|
798
|
+
"path/filepath"
|
|
799
|
+
"testing"
|
|
800
|
+
"time"
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
func TestNewManager(t *testing.T) {
|
|
804
|
+
mgr, err := NewManager()
|
|
805
|
+
if err != nil {
|
|
806
|
+
t.Fatalf("unexpected error: %v", err)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if mgr.configDir == "" {
|
|
810
|
+
t.Error("config dir should not be empty")
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Cleanup
|
|
814
|
+
os.RemoveAll(mgr.configDir)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
func TestProfileCRUD(t *testing.T) {
|
|
818
|
+
// Use temp dir for testing
|
|
819
|
+
tempDir := t.TempDir()
|
|
820
|
+
mgr := &Manager{configDir: tempDir}
|
|
821
|
+
|
|
822
|
+
// Create profile
|
|
823
|
+
profile := &Profile{
|
|
824
|
+
Name: "test",
|
|
825
|
+
PinchTabProfile: "linkedin-test",
|
|
826
|
+
CreatedAt: time.Now(),
|
|
827
|
+
LastUsed: time.Now(),
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if err := mgr.SaveProfile(profile); err != nil {
|
|
831
|
+
t.Fatalf("failed to save profile: %v", err)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Check exists
|
|
835
|
+
if !mgr.ProfileExists("test") {
|
|
836
|
+
t.Error("profile should exist")
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Load
|
|
840
|
+
loaded, err := mgr.LoadProfile("test")
|
|
841
|
+
if err != nil {
|
|
842
|
+
t.Fatalf("failed to load profile: %v", err)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if loaded.Name != "test" {
|
|
846
|
+
t.Errorf("expected name 'test', got %s", loaded.Name)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// List
|
|
850
|
+
profiles, err := mgr.ListProfiles()
|
|
851
|
+
if err != nil {
|
|
852
|
+
t.Fatalf("failed to list profiles: %v", err)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if len(profiles) != 1 || profiles[0] != "test" {
|
|
856
|
+
t.Errorf("expected ['test'], got %v", profiles)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Delete
|
|
860
|
+
if err := mgr.DeleteProfile("test"); err != nil {
|
|
861
|
+
t.Fatalf("failed to delete profile: %v", err)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if mgr.ProfileExists("test") {
|
|
865
|
+
t.Error("profile should not exist after delete")
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
func TestRateLimits(t *testing.T) {
|
|
870
|
+
tempDir := t.TempDir()
|
|
871
|
+
mgr := &Manager{configDir: tempDir}
|
|
872
|
+
|
|
873
|
+
limits := &RateLimits{
|
|
874
|
+
Connections: RateLimit{
|
|
875
|
+
Today: 15,
|
|
876
|
+
ThisWeek: 87,
|
|
877
|
+
},
|
|
878
|
+
Messages: RateLimit{
|
|
879
|
+
Today: 23,
|
|
880
|
+
},
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Save
|
|
884
|
+
if err := mgr.SaveRateLimits("john", limits); err != nil {
|
|
885
|
+
t.Fatalf("failed to save rate limits: %v", err)
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Load
|
|
889
|
+
loaded, err := mgr.LoadRateLimits("john")
|
|
890
|
+
if err != nil {
|
|
891
|
+
t.Fatalf("failed to load rate limits: %v", err)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if loaded.Connections.Today != 15 {
|
|
895
|
+
t.Errorf("expected 15 connections today, got %d", loaded.Connections.Today)
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
func TestRateLimitCanConnect(t *testing.T) {
|
|
900
|
+
limits := &RateLimits{
|
|
901
|
+
Connections: RateLimit{Today: 19, ThisWeek: 99},
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if !limits.CanConnect(20, 100) {
|
|
905
|
+
t.Error("should be able to connect at 19/20 daily")
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
limits.Connections.Today = 20
|
|
909
|
+
if limits.CanConnect(20, 100) {
|
|
910
|
+
t.Error("should not be able to connect at 20/20 daily")
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
**Step 4: Run tests**
|
|
916
|
+
|
|
917
|
+
```bash
|
|
918
|
+
go test ./internal/config/... -v
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
Expected: 4 tests PASS
|
|
922
|
+
|
|
923
|
+
**Step 5: Commit**
|
|
924
|
+
|
|
925
|
+
```bash
|
|
926
|
+
git add internal/config/
|
|
927
|
+
git commit -m "feat: add configuration and profile management"
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
---
|
|
931
|
+
|
|
932
|
+
## Task 4: Rate Limiting
|
|
933
|
+
|
|
934
|
+
**Files:**
|
|
935
|
+
- Create: `internal/ratelimit/limits.go`
|
|
936
|
+
- Create: `internal/ratelimit/limiter.go`
|
|
937
|
+
|
|
938
|
+
**Step 1: Create limits configuration**
|
|
939
|
+
|
|
940
|
+
Create: `internal/ratelimit/limits.go`
|
|
941
|
+
|
|
942
|
+
```go
|
|
943
|
+
package ratelimit
|
|
944
|
+
|
|
945
|
+
// Limits defines rate limit thresholds
|
|
946
|
+
type Limits struct {
|
|
947
|
+
ConnectionsDaily int
|
|
948
|
+
ConnectionsWeekly int
|
|
949
|
+
MessagesDaily int
|
|
950
|
+
MinDelaySeconds int
|
|
951
|
+
MaxDelaySeconds int
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// DefaultLimits returns default LinkedIn-safe limits
|
|
955
|
+
func DefaultLimits() Limits {
|
|
956
|
+
return Limits{
|
|
957
|
+
ConnectionsDaily: 20,
|
|
958
|
+
ConnectionsWeekly: 100,
|
|
959
|
+
MessagesDaily: 50,
|
|
960
|
+
MinDelaySeconds: 3,
|
|
961
|
+
MaxDelaySeconds: 8,
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// ConservativeLimits returns more conservative limits for new accounts
|
|
966
|
+
func ConservativeLimits() Limits {
|
|
967
|
+
return Limits{
|
|
968
|
+
ConnectionsDaily: 10,
|
|
969
|
+
ConnectionsWeekly: 50,
|
|
970
|
+
MessagesDaily: 25,
|
|
971
|
+
MinDelaySeconds: 5,
|
|
972
|
+
MaxDelaySeconds: 12,
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
**Step 2: Create limiter**
|
|
978
|
+
|
|
979
|
+
Create: `internal/ratelimit/limiter.go`
|
|
980
|
+
|
|
981
|
+
```go
|
|
982
|
+
package ratelimit
|
|
983
|
+
|
|
984
|
+
import (
|
|
985
|
+
"fmt"
|
|
986
|
+
"math/rand"
|
|
987
|
+
"time"
|
|
988
|
+
|
|
989
|
+
"github.com/thaddeus-git/linkedin-cli/internal/config"
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
// Limiter handles rate limiting logic
|
|
993
|
+
type Limiter struct {
|
|
994
|
+
limits Limits
|
|
995
|
+
config *config.Manager
|
|
996
|
+
profile string
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// NewLimiter creates a new rate limiter
|
|
1000
|
+
func NewLimiter(profile string, limits Limits, cfg *config.Manager) *Limiter {
|
|
1001
|
+
return &Limiter{
|
|
1002
|
+
limits: limits,
|
|
1003
|
+
config: cfg,
|
|
1004
|
+
profile: profile,
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// CheckConnection checks if a connection request is allowed
|
|
1009
|
+
func (l *Limiter) CheckConnection() error {
|
|
1010
|
+
state, err := l.config.LoadRateLimits(l.profile)
|
|
1011
|
+
if err != nil {
|
|
1012
|
+
return fmt.Errorf("failed to load rate limits: %w", err)
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if !state.CanConnect(l.limits.ConnectionsDaily, l.limits.ConnectionsWeekly) {
|
|
1016
|
+
return fmt.Errorf(
|
|
1017
|
+
"rate limit exceeded: %d/%d daily, %d/%d weekly connections",
|
|
1018
|
+
state.Connections.Today,
|
|
1019
|
+
l.limits.ConnectionsDaily,
|
|
1020
|
+
state.Connections.ThisWeek,
|
|
1021
|
+
l.limits.ConnectionsWeekly,
|
|
1022
|
+
)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return nil
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// CheckMessage checks if a message is allowed
|
|
1029
|
+
func (l *Limiter) CheckMessage() error {
|
|
1030
|
+
state, err := l.config.LoadRateLimits(l.profile)
|
|
1031
|
+
if err != nil {
|
|
1032
|
+
return fmt.Errorf("failed to load rate limits: %w", err)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if !state.CanMessage(l.limits.MessagesDaily) {
|
|
1036
|
+
return fmt.Errorf(
|
|
1037
|
+
"rate limit exceeded: %d/%d daily messages",
|
|
1038
|
+
state.Messages.Today,
|
|
1039
|
+
l.limits.MessagesDaily,
|
|
1040
|
+
)
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return nil
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// RecordConnection records a connection request
|
|
1047
|
+
func (l *Limiter) RecordConnection() error {
|
|
1048
|
+
state, err := l.config.LoadRateLimits(l.profile)
|
|
1049
|
+
if err != nil {
|
|
1050
|
+
return err
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
state.RecordConnection()
|
|
1054
|
+
return l.config.SaveRateLimits(l.profile, state)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// RecordMessage records a message
|
|
1058
|
+
func (l *Limiter) RecordMessage() error {
|
|
1059
|
+
state, err := l.config.LoadRateLimits(l.profile)
|
|
1060
|
+
if err != nil {
|
|
1061
|
+
return err
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
state.RecordMessage()
|
|
1065
|
+
return l.config.SaveRateLimits(l.profile, state)
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// GetDelay returns a random delay between min and max seconds
|
|
1069
|
+
func (l *Limiter) GetDelay() time.Duration {
|
|
1070
|
+
seconds := rand.Intn(l.limits.MaxDelaySeconds-l.limits.MinDelaySeconds+1) + l.limits.MinDelaySeconds
|
|
1071
|
+
return time.Duration(seconds) * time.Second
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Sleep delays execution for a random duration
|
|
1075
|
+
func (l *Limiter) Sleep() {
|
|
1076
|
+
time.Sleep(l.GetDelay())
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Status returns current rate limit status
|
|
1080
|
+
func (l *Limiter) Status() (map[string]interface{}, error) {
|
|
1081
|
+
state, err := l.config.LoadRateLimits(l.profile)
|
|
1082
|
+
if err != nil {
|
|
1083
|
+
return nil, err
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
return map[string]interface{}{
|
|
1087
|
+
"connections_today": state.Connections.Today,
|
|
1088
|
+
"connections_weekly": state.Connections.ThisWeek,
|
|
1089
|
+
"connections_limit": l.limits.ConnectionsDaily,
|
|
1090
|
+
"connections_weekly_limit": l.limits.ConnectionsWeekly,
|
|
1091
|
+
"messages_today": state.Messages.Today,
|
|
1092
|
+
"messages_limit": l.limits.MessagesDaily,
|
|
1093
|
+
"last_connection": state.Connections.LastAction,
|
|
1094
|
+
"last_message": state.Messages.LastAction,
|
|
1095
|
+
}, nil
|
|
1096
|
+
}
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
**Step 3: Commit**
|
|
1100
|
+
|
|
1101
|
+
```bash
|
|
1102
|
+
git add internal/ratelimit/
|
|
1103
|
+
git commit -m "feat: add rate limiting"
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
---
|
|
1107
|
+
|
|
1108
|
+
## Task 5: LinkedIn Selectors & Navigator
|
|
1109
|
+
|
|
1110
|
+
**Files:**
|
|
1111
|
+
- Create: `internal/linkedin/selectors.go`
|
|
1112
|
+
- Create: `internal/linkedin/navigator.go`
|
|
1113
|
+
- Create: `internal/linkedin/validator.go`
|
|
1114
|
+
|
|
1115
|
+
**Step 1: Create selectors**
|
|
1116
|
+
|
|
1117
|
+
Create: `internal/linkedin/selectors.go`
|
|
1118
|
+
|
|
1119
|
+
```go
|
|
1120
|
+
package linkedin
|
|
1121
|
+
|
|
1122
|
+
// Selectors contains LinkedIn DOM selectors
|
|
1123
|
+
// Note: These are fragile and may need updating as LinkedIn changes
|
|
1124
|
+
var Selectors = struct {
|
|
1125
|
+
// Connection buttons
|
|
1126
|
+
ConnectButton string
|
|
1127
|
+
ConnectButtonAlt string
|
|
1128
|
+
|
|
1129
|
+
// Connection modal
|
|
1130
|
+
ConnectModalTextarea string
|
|
1131
|
+
ConnectModalSend string
|
|
1132
|
+
ConnectModalCancel string
|
|
1133
|
+
|
|
1134
|
+
// Messaging
|
|
1135
|
+
MessageButton string
|
|
1136
|
+
MessageTextarea string
|
|
1137
|
+
MessageSendButton string
|
|
1138
|
+
|
|
1139
|
+
// Navigation
|
|
1140
|
+
ProfileName string
|
|
1141
|
+
ProfileHeadline string
|
|
1142
|
+
ProfileCompany string
|
|
1143
|
+
}{
|
|
1144
|
+
ConnectButton: "button[aria-label*='Connect']",
|
|
1145
|
+
ConnectButtonAlt: "button:has-text('Connect')",
|
|
1146
|
+
|
|
1147
|
+
ConnectModalTextarea: "textarea[name='message']",
|
|
1148
|
+
ConnectModalSend: "button[aria-label='Send now']",
|
|
1149
|
+
ConnectModalCancel: "button[aria-label='Dismiss']",
|
|
1150
|
+
|
|
1151
|
+
MessageButton: "button[aria-label*='Message']",
|
|
1152
|
+
MessageTextarea: "div[role='textbox']",
|
|
1153
|
+
MessageSendButton: "button[type='submit']",
|
|
1154
|
+
|
|
1155
|
+
ProfileName: "h1",
|
|
1156
|
+
ProfileHeadline: "div.text-body-medium",
|
|
1157
|
+
ProfileCompany: "a[href*='/company/']",
|
|
1158
|
+
}
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
**Step 2: Create navigator**
|
|
1162
|
+
|
|
1163
|
+
Create: `internal/linkedin/navigator.go`
|
|
1164
|
+
|
|
1165
|
+
```go
|
|
1166
|
+
package linkedin
|
|
1167
|
+
|
|
1168
|
+
import (
|
|
1169
|
+
"fmt"
|
|
1170
|
+
"strings"
|
|
1171
|
+
"time"
|
|
1172
|
+
|
|
1173
|
+
"github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
// Navigator handles LinkedIn page interactions
|
|
1177
|
+
type Navigator struct {
|
|
1178
|
+
client *pinchtab.Client
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// NewNavigator creates a new navigator
|
|
1182
|
+
func NewNavigator(client *pinchtab.Client) *Navigator {
|
|
1183
|
+
return &Navigator{client: client}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// NavigateToProfile navigates to a LinkedIn profile
|
|
1187
|
+
func (n *Navigator) NavigateToProfile(tabID string, profileURL string) error {
|
|
1188
|
+
// Ensure URL is absolute
|
|
1189
|
+
if !strings.HasPrefix(profileURL, "http") {
|
|
1190
|
+
profileURL = "https://linkedin.com/in/" + strings.TrimPrefix(profileURL, "/in/")
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
req := pinchtab.NavigateRequest{
|
|
1194
|
+
URL: profileURL,
|
|
1195
|
+
TimeoutSeconds: 30,
|
|
1196
|
+
BlockImages: true,
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
if err := n.client.Navigate(tabID, req); err != nil {
|
|
1200
|
+
return fmt.Errorf("failed to navigate: %w", err)
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Wait for page to load
|
|
1204
|
+
time.Sleep(3 * time.Second)
|
|
1205
|
+
|
|
1206
|
+
return nil
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// FindConnectButton finds the connect button in the snapshot
|
|
1210
|
+
func (n *Navigator) FindConnectButton(snapshot *pinchtab.Snapshot) (*pinchtab.Element, error) {
|
|
1211
|
+
for _, elem := range snapshot.Elements {
|
|
1212
|
+
if elem.Role == "button" && strings.Contains(strings.ToLower(elem.Name), "connect") {
|
|
1213
|
+
return &elem, nil
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
return nil, fmt.Errorf("connect button not found")
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// FindMessageButton finds the message button in the snapshot
|
|
1221
|
+
func (n *Navigator) FindMessageButton(snapshot *pinchtab.Snapshot) (*pinchtab.Element, error) {
|
|
1222
|
+
for _, elem := range snapshot.Elements {
|
|
1223
|
+
if elem.Role == "button" && strings.Contains(strings.ToLower(elem.Name), "message") {
|
|
1224
|
+
return &elem, nil
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
return nil, fmt.Errorf("message button not found")
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// ClickConnect clicks the connect button
|
|
1232
|
+
func (n *Navigator) ClickConnect(tabID string) error {
|
|
1233
|
+
snapshot, err := n.client.GetSnapshot(tabID, "interactive")
|
|
1234
|
+
if err != nil {
|
|
1235
|
+
return fmt.Errorf("failed to get snapshot: %w", err)
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
button, err := n.FindConnectButton(snapshot)
|
|
1239
|
+
if err != nil {
|
|
1240
|
+
return err
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
if err := n.client.HumanClick(tabID, button.Ref); err != nil {
|
|
1244
|
+
return fmt.Errorf("failed to click connect: %w", err)
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Wait for modal
|
|
1248
|
+
time.Sleep(2 * time.Second)
|
|
1249
|
+
|
|
1250
|
+
return nil
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// SendConnectionRequest sends a connection request with optional note
|
|
1254
|
+
func (n *Navigator) SendConnectionRequest(tabID string, note string) error {
|
|
1255
|
+
if note != "" {
|
|
1256
|
+
// Check for textarea in modal
|
|
1257
|
+
snapshot, err := n.client.GetSnapshot(tabID, "interactive")
|
|
1258
|
+
if err != nil {
|
|
1259
|
+
return fmt.Errorf("failed to get modal snapshot: %w", err)
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// Find textarea
|
|
1263
|
+
var textarea *pinchtab.Element
|
|
1264
|
+
for _, elem := range snapshot.Elements {
|
|
1265
|
+
if elem.Role == "textbox" || strings.Contains(strings.ToLower(elem.Name), "message") {
|
|
1266
|
+
textarea = &elem
|
|
1267
|
+
break
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
if textarea != nil {
|
|
1272
|
+
// Type the note
|
|
1273
|
+
if err := n.client.HumanType(tabID, textarea.Ref, note); err != nil {
|
|
1274
|
+
return fmt.Errorf("failed to type note: %w", err)
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Find and click send button
|
|
1280
|
+
snapshot, err := n.client.GetSnapshot(tabID, "interactive")
|
|
1281
|
+
if err != nil {
|
|
1282
|
+
return fmt.Errorf("failed to get snapshot for send: %w", err)
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
var sendButton *pinchtab.Element
|
|
1286
|
+
for _, elem := range snapshot.Elements {
|
|
1287
|
+
if elem.Role == "button" && strings.Contains(strings.ToLower(elem.Name), "send") {
|
|
1288
|
+
sendButton = &elem
|
|
1289
|
+
break
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if sendButton == nil {
|
|
1294
|
+
return fmt.Errorf("send button not found")
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
if err := n.client.HumanClick(tabID, sendButton.Ref); err != nil {
|
|
1298
|
+
return fmt.Errorf("failed to click send: %w", err)
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
return nil
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// OpenMessageModal opens the message modal
|
|
1305
|
+
func (n *Navigator) OpenMessageModal(tabID string) error {
|
|
1306
|
+
snapshot, err := n.client.GetSnapshot(tabID, "interactive")
|
|
1307
|
+
if err != nil {
|
|
1308
|
+
return fmt.Errorf("failed to get snapshot: %w", err)
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
button, err := n.FindMessageButton(snapshot)
|
|
1312
|
+
if err != nil {
|
|
1313
|
+
return err
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
if err := n.client.HumanClick(tabID, button.Ref); err != nil {
|
|
1317
|
+
return fmt.Errorf("failed to click message button: %w", err)
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
time.Sleep(2 * time.Second)
|
|
1321
|
+
return nil
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// SendMessage sends a direct message
|
|
1325
|
+
func (n *Navigator) SendMessage(tabID string, message string) error {
|
|
1326
|
+
snapshot, err := n.client.GetSnapshot(tabID, "interactive")
|
|
1327
|
+
if err != nil {
|
|
1328
|
+
return fmt.Errorf("failed to get snapshot: %w", err)
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Find message textarea
|
|
1332
|
+
var textarea *pinchtab.Element
|
|
1333
|
+
for _, elem := range snapshot.Elements {
|
|
1334
|
+
if elem.Role == "textbox" || elem.Type == "textbox" {
|
|
1335
|
+
textarea = &elem
|
|
1336
|
+
break
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
if textarea == nil {
|
|
1341
|
+
return fmt.Errorf("message textarea not found")
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// Type message
|
|
1345
|
+
if err := n.client.HumanType(tabID, textarea.Ref, message); err != nil {
|
|
1346
|
+
return fmt.Errorf("failed to type message: %w", err)
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Find send button
|
|
1350
|
+
snapshot, err = n.client.GetSnapshot(tabID, "interactive")
|
|
1351
|
+
if err != nil {
|
|
1352
|
+
return fmt.Errorf("failed to get snapshot for send: %w", err)
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
var sendButton *pinchtab.Element
|
|
1356
|
+
for _, elem := range snapshot.Elements {
|
|
1357
|
+
if elem.Role == "button" && strings.Contains(strings.ToLower(elem.Name), "send") {
|
|
1358
|
+
sendButton = &elem
|
|
1359
|
+
break
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
if sendButton == nil {
|
|
1364
|
+
return fmt.Errorf("send button not found")
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
if err := n.client.HumanClick(tabID, sendButton.Ref); err != nil {
|
|
1368
|
+
return fmt.Errorf("failed to click send: %w", err)
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
return nil
|
|
1372
|
+
}
|
|
1373
|
+
```
|
|
1374
|
+
|
|
1375
|
+
**Step 3: Create validator**
|
|
1376
|
+
|
|
1377
|
+
Create: `internal/linkedin/validator.go`
|
|
1378
|
+
|
|
1379
|
+
```go
|
|
1380
|
+
package linkedin
|
|
1381
|
+
|
|
1382
|
+
import (
|
|
1383
|
+
"fmt"
|
|
1384
|
+
"net/url"
|
|
1385
|
+
"strings"
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
// ValidateProfileURL validates a LinkedIn profile URL
|
|
1389
|
+
func ValidateProfileURL(input string) (string, error) {
|
|
1390
|
+
// Normalize input
|
|
1391
|
+
input = strings.TrimSpace(input)
|
|
1392
|
+
|
|
1393
|
+
// Check if it's already a full URL
|
|
1394
|
+
if strings.HasPrefix(input, "http") {
|
|
1395
|
+
u, err := url.Parse(input)
|
|
1396
|
+
if err != nil {
|
|
1397
|
+
return "", fmt.Errorf("invalid URL: %w", err)
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if !strings.Contains(u.Host, "linkedin.com") {
|
|
1401
|
+
return "", fmt.Errorf("URL must be from linkedin.com")
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
if !strings.Contains(u.Path, "/in/") {
|
|
1405
|
+
return "", fmt.Errorf("URL must be a LinkedIn profile (contains /in/)")
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
return input, nil
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Handle vanity URL format (linkedin.com/in/username or /in/username)
|
|
1412
|
+
if strings.HasPrefix(input, "linkedin.com/in/") || strings.HasPrefix(input, "/in/") {
|
|
1413
|
+
username := strings.TrimPrefix(input, "linkedin.com/in/")
|
|
1414
|
+
username = strings.TrimPrefix(username, "/in/")
|
|
1415
|
+
username = strings.TrimSuffix(username, "/")
|
|
1416
|
+
|
|
1417
|
+
if username == "" {
|
|
1418
|
+
return "", fmt.Errorf("invalid LinkedIn profile URL")
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
return fmt.Sprintf("https://linkedin.com/in/%s", username), nil
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// Assume it's just a username
|
|
1425
|
+
return fmt.Sprintf("https://linkedin.com/in/%s", input), nil
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// ExtractProfileUsername extracts the username from a LinkedIn profile URL
|
|
1429
|
+
func ExtractProfileUsername(profileURL string) string {
|
|
1430
|
+
u, err := url.Parse(profileURL)
|
|
1431
|
+
if err != nil {
|
|
1432
|
+
return ""
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
parts := strings.Split(u.Path, "/")
|
|
1436
|
+
for i, part := range parts {
|
|
1437
|
+
if part == "in" && i+1 < len(parts) {
|
|
1438
|
+
return parts[i+1]
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
return ""
|
|
1443
|
+
}
|
|
1444
|
+
```
|
|
1445
|
+
|
|
1446
|
+
**Step 4: Commit**
|
|
1447
|
+
|
|
1448
|
+
```bash
|
|
1449
|
+
git add internal/linkedin/
|
|
1450
|
+
git commit -m "feat: add LinkedIn selectors and navigator"
|
|
1451
|
+
```
|
|
1452
|
+
|
|
1453
|
+
---
|
|
1454
|
+
|
|
1455
|
+
## Task 6: CLI Commands
|
|
1456
|
+
|
|
1457
|
+
**Files:**
|
|
1458
|
+
- Create: `internal/cmd/root.go`
|
|
1459
|
+
- Create: `internal/cmd/auth.go`
|
|
1460
|
+
- Create: `internal/cmd/connect.go`
|
|
1461
|
+
- Create: `internal/cmd/message.go`
|
|
1462
|
+
- Create: `cmd/linkedin/main.go`
|
|
1463
|
+
|
|
1464
|
+
**Step 1: Create root command**
|
|
1465
|
+
|
|
1466
|
+
Create: `internal/cmd/root.go`
|
|
1467
|
+
|
|
1468
|
+
```go
|
|
1469
|
+
package cmd
|
|
1470
|
+
|
|
1471
|
+
import (
|
|
1472
|
+
"fmt"
|
|
1473
|
+
"os"
|
|
1474
|
+
|
|
1475
|
+
"github.com/spf13/cobra"
|
|
1476
|
+
"github.com/thaddeus-git/linkedin-cli/internal/config"
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
var (
|
|
1480
|
+
profileName string
|
|
1481
|
+
dryRun bool
|
|
1482
|
+
verbose bool
|
|
1483
|
+
)
|
|
1484
|
+
|
|
1485
|
+
// rootCmd represents the base command
|
|
1486
|
+
var rootCmd = &cobra.Command{
|
|
1487
|
+
Use: "linkedin",
|
|
1488
|
+
Short: "LinkedIn automation CLI",
|
|
1489
|
+
Long: `A CLI tool for LinkedIn automation using PinchTab.
|
|
1490
|
+
|
|
1491
|
+
Prerequisites:
|
|
1492
|
+
1. Install PinchTab: curl -fsSL https://pinchtab.com/install.sh | bash
|
|
1493
|
+
2. Start PinchTab: pinchtab
|
|
1494
|
+
|
|
1495
|
+
Examples:
|
|
1496
|
+
# Authenticate a profile
|
|
1497
|
+
linkedin auth --profile john
|
|
1498
|
+
|
|
1499
|
+
# Send a connection request
|
|
1500
|
+
linkedin connect --profile john --url linkedin.com/in/alice
|
|
1501
|
+
|
|
1502
|
+
# Send a message
|
|
1503
|
+
linkedin message --profile john --url linkedin.com/in/alice --message "Hello!"
|
|
1504
|
+
`,
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// Execute adds all child commands to the root command
|
|
1508
|
+
func Execute() {
|
|
1509
|
+
if err := rootCmd.Execute(); err != nil {
|
|
1510
|
+
fmt.Fprintln(os.Stderr, err)
|
|
1511
|
+
os.Exit(1)
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
func init() {
|
|
1516
|
+
rootCmd.PersistentFlags().StringVarP(&profileName, "profile", "p", "", "Profile name (env: LINKEDIN_PROFILE)")
|
|
1517
|
+
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Show what would be done without executing")
|
|
1518
|
+
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output")
|
|
1519
|
+
|
|
1520
|
+
// Set profile from environment if not provided
|
|
1521
|
+
if profileName == "" {
|
|
1522
|
+
profileName = os.Getenv("LINKEDIN_PROFILE")
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// getConfigManager returns a config manager
|
|
1527
|
+
func getConfigManager() (*config.Manager, error) {
|
|
1528
|
+
return config.NewManager()
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// getPinchTabHost returns PinchTab host from environment
|
|
1532
|
+
func getPinchTabHost() string {
|
|
1533
|
+
host := os.Getenv("PINCHTAB_HOST")
|
|
1534
|
+
if host == "" {
|
|
1535
|
+
return "http://localhost:9867"
|
|
1536
|
+
}
|
|
1537
|
+
return host
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// exitWithError prints error and exits
|
|
1541
|
+
func exitWithError(format string, args ...interface{}) {
|
|
1542
|
+
fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...)
|
|
1543
|
+
os.Exit(1)
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// logVerbose prints if verbose mode is enabled
|
|
1547
|
+
func logVerbose(format string, args ...interface{}) {
|
|
1548
|
+
if verbose {
|
|
1549
|
+
fmt.Printf("[verbose] "+format+"\n", args...)
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
```
|
|
1553
|
+
|
|
1554
|
+
**Step 2: Create auth command**
|
|
1555
|
+
|
|
1556
|
+
Create: `internal/cmd/auth.go`
|
|
1557
|
+
|
|
1558
|
+
```go
|
|
1559
|
+
package cmd
|
|
1560
|
+
|
|
1561
|
+
import (
|
|
1562
|
+
"fmt"
|
|
1563
|
+
"os"
|
|
1564
|
+
"time"
|
|
1565
|
+
|
|
1566
|
+
"github.com/spf13/cobra"
|
|
1567
|
+
"github.com/thaddeus-git/linkedin-cli/internal/config"
|
|
1568
|
+
"github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
|
|
1569
|
+
)
|
|
1570
|
+
|
|
1571
|
+
func init() {
|
|
1572
|
+
rootCmd.AddCommand(authCmd)
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
var authCmd = &cobra.Command{
|
|
1576
|
+
Use: "auth",
|
|
1577
|
+
Short: "Authenticate a LinkedIn profile",
|
|
1578
|
+
Long: `Authenticate a LinkedIn profile by logging in through PinchTab.
|
|
1579
|
+
|
|
1580
|
+
This command will:
|
|
1581
|
+
1. Start a PinchTab browser instance
|
|
1582
|
+
2. Navigate to LinkedIn login page
|
|
1583
|
+
3. Wait for you to log in manually
|
|
1584
|
+
4. Save the session for future use
|
|
1585
|
+
|
|
1586
|
+
Example:
|
|
1587
|
+
linkedin auth --profile john
|
|
1588
|
+
`,
|
|
1589
|
+
Run: runAuth,
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
func runAuth(cmd *cobra.Command, args []string) {
|
|
1593
|
+
if profileName == "" {
|
|
1594
|
+
exitWithError("Profile name is required (--profile or LINKEDIN_PROFILE)")
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
cfg, err := getConfigManager()
|
|
1598
|
+
if err != nil {
|
|
1599
|
+
exitWithError("Failed to initialize config: %v", err)
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// Check if PinchTab is running
|
|
1603
|
+
client := pinchtab.NewClient(getPinchTabHost())
|
|
1604
|
+
|
|
1605
|
+
fmt.Printf("Starting authentication for profile '%s'...\n", profileName)
|
|
1606
|
+
logVerbose("Using PinchTab at %s", getPinchTabHost())
|
|
1607
|
+
|
|
1608
|
+
if dryRun {
|
|
1609
|
+
fmt.Println("[dry-run] Would:")
|
|
1610
|
+
fmt.Printf(" - Start PinchTab instance with profile 'linkedin-%s'\n", profileName)
|
|
1611
|
+
fmt.Println(" - Navigate to https://linkedin.com/login")
|
|
1612
|
+
fmt.Println(" - Wait for manual login")
|
|
1613
|
+
fmt.Println(" - Save profile configuration")
|
|
1614
|
+
return
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// Start instance
|
|
1618
|
+
pinchTabProfile := fmt.Sprintf("linkedin-%s", profileName)
|
|
1619
|
+
fmt.Println("Starting browser instance...")
|
|
1620
|
+
instance, err := client.StartInstance(pinchTabProfile)
|
|
1621
|
+
if err != nil {
|
|
1622
|
+
exitWithError("Failed to start PinchTab instance. Is PinchTab running? (%v)", err)
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
defer func() {
|
|
1626
|
+
fmt.Println("Stopping browser instance...")
|
|
1627
|
+
client.StopInstance(instance.ID)
|
|
1628
|
+
}()
|
|
1629
|
+
|
|
1630
|
+
// Navigate to login
|
|
1631
|
+
tab, err := client.NewTab(instance.ID, "https://linkedin.com/login")
|
|
1632
|
+
if err != nil {
|
|
1633
|
+
exitWithError("Failed to create tab: %v", err)
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
fmt.Println("\n========================================")
|
|
1637
|
+
fmt.Println("A browser window has opened.")
|
|
1638
|
+
fmt.Println("Please log in to LinkedIn manually.")
|
|
1639
|
+
fmt.Println("Press Enter when you're logged in...")
|
|
1640
|
+
fmt.Println("========================================\n")
|
|
1641
|
+
|
|
1642
|
+
os.Stdin.Read(make([]byte, 1))
|
|
1643
|
+
|
|
1644
|
+
// Verify we're logged in by checking for profile menu
|
|
1645
|
+
text, err := client.GetText(tab.ID)
|
|
1646
|
+
if err != nil {
|
|
1647
|
+
exitWithError("Failed to get page text: %v", err)
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
if !containsAny(text.Text, []string{"Me", "Messaging", "Notifications"}) {
|
|
1651
|
+
fmt.Println("Warning: Could not verify login. Session may not be saved.")
|
|
1652
|
+
fmt.Println("Please try logging in again.")
|
|
1653
|
+
return
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// Save profile
|
|
1657
|
+
profile := &config.Profile{
|
|
1658
|
+
Name: profileName,
|
|
1659
|
+
PinchTabProfile: pinchTabProfile,
|
|
1660
|
+
CreatedAt: time.Now(),
|
|
1661
|
+
LastUsed: time.Now(),
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
if err := cfg.SaveProfile(profile); err != nil {
|
|
1665
|
+
exitWithError("Failed to save profile: %v", err)
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
fmt.Printf("\n✓ Profile '%s' authenticated successfully!\n", profileName)
|
|
1669
|
+
fmt.Println("You can now use this profile for automation.")
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
func containsAny(text string, substrs []string) bool {
|
|
1673
|
+
for _, substr := range substrs {
|
|
1674
|
+
if contains(text, substr) {
|
|
1675
|
+
return true
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
return false
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
func contains(text, substr string) bool {
|
|
1682
|
+
return len(text) > 0 && len(substr) > 0 &&
|
|
1683
|
+
(len(text) > len(substr) && (text[:len(substr)] == substr ||
|
|
1684
|
+
contains(text[1:], substr)))
|
|
1685
|
+
}
|
|
1686
|
+
```
|
|
1687
|
+
|
|
1688
|
+
**Step 3: Create connect command**
|
|
1689
|
+
|
|
1690
|
+
Create: `internal/cmd/connect.go`
|
|
1691
|
+
|
|
1692
|
+
```go
|
|
1693
|
+
package cmd
|
|
1694
|
+
|
|
1695
|
+
import (
|
|
1696
|
+
"fmt"
|
|
1697
|
+
|
|
1698
|
+
"github.com/spf13/cobra"
|
|
1699
|
+
"github.com/thaddeus-git/linkedin-cli/internal/linkedin"
|
|
1700
|
+
"github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
|
|
1701
|
+
"github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
var (
|
|
1705
|
+
connectURL string
|
|
1706
|
+
connectNote string
|
|
1707
|
+
)
|
|
1708
|
+
|
|
1709
|
+
func init() {
|
|
1710
|
+
rootCmd.AddCommand(connectCmd)
|
|
1711
|
+
connectCmd.Flags().StringVarP(&connectURL, "url", "u", "", "LinkedIn profile URL (required)")
|
|
1712
|
+
connectCmd.Flags().StringVarP(&connectNote, "message", "m", "", "Connection request note (optional)")
|
|
1713
|
+
connectCmd.MarkFlagRequired("url")
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
var connectCmd = &cobra.Command{
|
|
1717
|
+
Use: "connect",
|
|
1718
|
+
Short: "Send a connection request",
|
|
1719
|
+
Long: `Send a connection request to a LinkedIn profile.
|
|
1720
|
+
|
|
1721
|
+
Examples:
|
|
1722
|
+
linkedin connect --profile john --url linkedin.com/in/alice
|
|
1723
|
+
linkedin connect --profile john --url linkedin.com/in/alice --message "Hi Alice, loved your post!"
|
|
1724
|
+
`,
|
|
1725
|
+
Run: runConnect,
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
func runConnect(cmd *cobra.Command, args []string) {
|
|
1729
|
+
if profileName == "" {
|
|
1730
|
+
exitWithError("Profile name is required (--profile or LINKEDIN_PROFILE)")
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// Validate URL
|
|
1734
|
+
profileURL, err := linkedin.ValidateProfileURL(connectURL)
|
|
1735
|
+
if err != nil {
|
|
1736
|
+
exitWithError("Invalid URL: %v", err)
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
cfg, err := getConfigManager()
|
|
1740
|
+
if err != nil {
|
|
1741
|
+
exitWithError("Failed to initialize config: %v", err)
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// Load profile
|
|
1745
|
+
profile, err := cfg.LoadProfile(profileName)
|
|
1746
|
+
if err != nil {
|
|
1747
|
+
exitWithError("Failed to load profile: %v", err)
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Check rate limits
|
|
1751
|
+
limiter := ratelimit.NewLimiter(profileName, ratelimit.DefaultLimits(), cfg)
|
|
1752
|
+
if err := limiter.CheckConnection(); err != nil {
|
|
1753
|
+
exitWithError("Rate limit check failed: %v", err)
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
if dryRun {
|
|
1757
|
+
fmt.Println("[dry-run] Would:")
|
|
1758
|
+
fmt.Printf(" - Navigate to: %s\n", profileURL)
|
|
1759
|
+
fmt.Println(" - Click Connect button")
|
|
1760
|
+
if connectNote != "" {
|
|
1761
|
+
fmt.Printf(" - Type note: %s\n", connectNote)
|
|
1762
|
+
}
|
|
1763
|
+
fmt.Println(" - Click Send")
|
|
1764
|
+
return
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// Initialize PinchTab client
|
|
1768
|
+
client := pinchtab.NewClient(getPinchTabHost())
|
|
1769
|
+
navigator := linkedin.NewNavigator(client)
|
|
1770
|
+
|
|
1771
|
+
fmt.Printf("Sending connection request to %s...\n", profileURL)
|
|
1772
|
+
logVerbose("Using profile: %s", profileName)
|
|
1773
|
+
|
|
1774
|
+
// Start instance
|
|
1775
|
+
logVerbose("Starting PinchTab instance...")
|
|
1776
|
+
instance, err := client.StartInstance(profile.PinchTabProfile)
|
|
1777
|
+
if err != nil {
|
|
1778
|
+
exitWithError("Failed to start PinchTab instance: %v", err)
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
defer func() {
|
|
1782
|
+
logVerbose("Stopping instance...")
|
|
1783
|
+
client.StopInstance(instance.ID)
|
|
1784
|
+
}()
|
|
1785
|
+
|
|
1786
|
+
// Create tab and navigate
|
|
1787
|
+
tab, err := client.NewTab(instance.ID, profileURL)
|
|
1788
|
+
if err != nil {
|
|
1789
|
+
exitWithError("Failed to create tab: %v", err)
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
logVerbose("Navigating to profile...")
|
|
1793
|
+
if err := navigator.NavigateToProfile(tab.ID, profileURL); err != nil {
|
|
1794
|
+
exitWithError("Failed to navigate: %v", err)
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// Click connect
|
|
1798
|
+
logVerbose("Clicking Connect button...")
|
|
1799
|
+
if err := navigator.ClickConnect(tab.ID); err != nil {
|
|
1800
|
+
exitWithError("Failed to click connect: %v", err)
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// Send with note if provided
|
|
1804
|
+
logVerbose("Sending connection request...")
|
|
1805
|
+
if err := navigator.SendConnectionRequest(tab.ID, connectNote); err != nil {
|
|
1806
|
+
exitWithError("Failed to send request: %v", err)
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// Record success
|
|
1810
|
+
if err := limiter.RecordConnection(); err != nil {
|
|
1811
|
+
fmt.Printf("Warning: Failed to record connection: %v\n", err)
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// Update last used
|
|
1815
|
+
cfg.UpdateLastUsed(profileName)
|
|
1816
|
+
|
|
1817
|
+
fmt.Println("✓ Connection request sent successfully!")
|
|
1818
|
+
|
|
1819
|
+
// Apply rate limiting delay
|
|
1820
|
+
delay := limiter.GetDelay()
|
|
1821
|
+
logVerbose("Waiting %v before exiting...", delay)
|
|
1822
|
+
limiter.Sleep()
|
|
1823
|
+
}
|
|
1824
|
+
```
|
|
1825
|
+
|
|
1826
|
+
**Step 4: Create message command**
|
|
1827
|
+
|
|
1828
|
+
Create: `internal/cmd/message.go`
|
|
1829
|
+
|
|
1830
|
+
```go
|
|
1831
|
+
package cmd
|
|
1832
|
+
|
|
1833
|
+
import (
|
|
1834
|
+
"fmt"
|
|
1835
|
+
|
|
1836
|
+
"github.com/spf13/cobra"
|
|
1837
|
+
"github.com/thaddeus-git/linkedin-cli/internal/linkedin"
|
|
1838
|
+
"github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
|
|
1839
|
+
"github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
|
|
1840
|
+
)
|
|
1841
|
+
|
|
1842
|
+
var (
|
|
1843
|
+
messageURL string
|
|
1844
|
+
messageContent string
|
|
1845
|
+
)
|
|
1846
|
+
|
|
1847
|
+
func init() {
|
|
1848
|
+
rootCmd.AddCommand(messageCmd)
|
|
1849
|
+
messageCmd.Flags().StringVarP(&messageURL, "url", "u", "", "LinkedIn profile URL (required)")
|
|
1850
|
+
messageCmd.Flags().StringVarP(&messageContent, "message", "m", "", "Message content (required)")
|
|
1851
|
+
messageCmd.MarkFlagRequired("url")
|
|
1852
|
+
messageCmd.MarkFlagRequired("message")
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
var messageCmd = &cobra.Command{
|
|
1856
|
+
Use: "message",
|
|
1857
|
+
Short: "Send a direct message",
|
|
1858
|
+
Long: `Send a direct message to a LinkedIn connection.
|
|
1859
|
+
|
|
1860
|
+
You must already be connected with the recipient.
|
|
1861
|
+
|
|
1862
|
+
Examples:
|
|
1863
|
+
linkedin message --profile john --url linkedin.com/in/alice --message "Thanks for connecting!"
|
|
1864
|
+
`,
|
|
1865
|
+
Run: runMessage,
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
func runMessage(cmd *cobra.Command, args []string) {
|
|
1869
|
+
if profileName == "" {
|
|
1870
|
+
exitWithError("Profile name is required (--profile or LINKEDIN_PROFILE)")
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// Validate URL
|
|
1874
|
+
profileURL, err := linkedin.ValidateProfileURL(messageURL)
|
|
1875
|
+
if err != nil {
|
|
1876
|
+
exitWithError("Invalid URL: %v", err)
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
cfg, err := getConfigManager()
|
|
1880
|
+
if err != nil {
|
|
1881
|
+
exitWithError("Failed to initialize config: %v", err)
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// Load profile
|
|
1885
|
+
profile, err := cfg.LoadProfile(profileName)
|
|
1886
|
+
if err != nil {
|
|
1887
|
+
exitWithError("Failed to load profile: %v", err)
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// Check rate limits
|
|
1891
|
+
limiter := ratelimit.NewLimiter(profileName, ratelimit.DefaultLimits(), cfg)
|
|
1892
|
+
if err := limiter.CheckMessage(); err != nil {
|
|
1893
|
+
exitWithError("Rate limit check failed: %v", err)
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
if dryRun {
|
|
1897
|
+
fmt.Println("[dry-run] Would:")
|
|
1898
|
+
fmt.Printf(" - Navigate to: %s\n", profileURL)
|
|
1899
|
+
fmt.Println(" - Click Message button")
|
|
1900
|
+
fmt.Printf(" - Type message: %s\n", messageContent)
|
|
1901
|
+
fmt.Println(" - Click Send")
|
|
1902
|
+
return
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// Initialize PinchTab client
|
|
1906
|
+
client := pinchtab.NewClient(getPinchTabHost())
|
|
1907
|
+
navigator := linkedin.NewNavigator(client)
|
|
1908
|
+
|
|
1909
|
+
fmt.Printf("Sending message to %s...\n", profileURL)
|
|
1910
|
+
logVerbose("Using profile: %s", profileName)
|
|
1911
|
+
|
|
1912
|
+
// Start instance
|
|
1913
|
+
logVerbose("Starting PinchTab instance...")
|
|
1914
|
+
instance, err := client.StartInstance(profile.PinchTabProfile)
|
|
1915
|
+
if err != nil {
|
|
1916
|
+
exitWithError("Failed to start PinchTab instance: %v", err)
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
defer func() {
|
|
1920
|
+
logVerbose("Stopping instance...")
|
|
1921
|
+
client.StopInstance(instance.ID)
|
|
1922
|
+
}()
|
|
1923
|
+
|
|
1924
|
+
// Create tab and navigate
|
|
1925
|
+
tab, err := client.NewTab(instance.ID, profileURL)
|
|
1926
|
+
if err != nil {
|
|
1927
|
+
exitWithError("Failed to create tab: %v", err)
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
logVerbose("Navigating to profile...")
|
|
1931
|
+
if err := navigator.NavigateToProfile(tab.ID, profileURL); err != nil {
|
|
1932
|
+
exitWithError("Failed to navigate: %v", err)
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// Open message modal
|
|
1936
|
+
logVerbose("Opening message modal...")
|
|
1937
|
+
if err := navigator.OpenMessageModal(tab.ID); err != nil {
|
|
1938
|
+
exitWithError("Failed to open message modal: %v", err)
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// Send message
|
|
1942
|
+
logVerbose("Sending message...")
|
|
1943
|
+
if err := navigator.SendMessage(tab.ID, messageContent); err != nil {
|
|
1944
|
+
exitWithError("Failed to send message: %v", err)
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// Record success
|
|
1948
|
+
if err := limiter.RecordMessage(); err != nil {
|
|
1949
|
+
fmt.Printf("Warning: Failed to record message: %v\n", err)
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// Update last used
|
|
1953
|
+
cfg.UpdateLastUsed(profileName)
|
|
1954
|
+
|
|
1955
|
+
fmt.Println("✓ Message sent successfully!")
|
|
1956
|
+
|
|
1957
|
+
// Apply rate limiting delay
|
|
1958
|
+
delay := limiter.GetDelay()
|
|
1959
|
+
logVerbose("Waiting %v before exiting...", delay)
|
|
1960
|
+
limiter.Sleep()
|
|
1961
|
+
}
|
|
1962
|
+
```
|
|
1963
|
+
|
|
1964
|
+
**Step 5: Create main.go**
|
|
1965
|
+
|
|
1966
|
+
Create: `cmd/linkedin/main.go`
|
|
1967
|
+
|
|
1968
|
+
```go
|
|
1969
|
+
package main
|
|
1970
|
+
|
|
1971
|
+
import (
|
|
1972
|
+
"github.com/thaddeus-git/linkedin-cli/internal/cmd"
|
|
1973
|
+
)
|
|
1974
|
+
|
|
1975
|
+
func main() {
|
|
1976
|
+
cmd.Execute()
|
|
1977
|
+
}
|
|
1978
|
+
```
|
|
1979
|
+
|
|
1980
|
+
**Step 6: Add Cobra dependency**
|
|
1981
|
+
|
|
1982
|
+
```bash
|
|
1983
|
+
cd /Users/thaddeus/projects/linkedin-cli
|
|
1984
|
+
go get github.com/spf13/cobra
|
|
1985
|
+
```
|
|
1986
|
+
|
|
1987
|
+
**Step 7: Build and test**
|
|
1988
|
+
|
|
1989
|
+
```bash
|
|
1990
|
+
go build -o linkedin-cli ./cmd/linkedin
|
|
1991
|
+
./linkedin-cli --help
|
|
1992
|
+
```
|
|
1993
|
+
|
|
1994
|
+
Expected: Shows help output with auth, connect, message commands
|
|
1995
|
+
|
|
1996
|
+
**Step 8: Commit**
|
|
1997
|
+
|
|
1998
|
+
```bash
|
|
1999
|
+
git add go.mod go.sum internal/cmd/ cmd/
|
|
2000
|
+
git commit -m "feat: add CLI commands (auth, connect, message)"
|
|
2001
|
+
```
|
|
2002
|
+
|
|
2003
|
+
---
|
|
2004
|
+
|
|
2005
|
+
## Task 7: Profiles Subcommand
|
|
2006
|
+
|
|
2007
|
+
**Files:**
|
|
2008
|
+
- Create: `internal/cmd/profiles.go`
|
|
2009
|
+
|
|
2010
|
+
**Step 1: Create profiles command**
|
|
2011
|
+
|
|
2012
|
+
Create: `internal/cmd/profiles.go`
|
|
2013
|
+
|
|
2014
|
+
```go
|
|
2015
|
+
package cmd
|
|
2016
|
+
|
|
2017
|
+
import (
|
|
2018
|
+
"fmt"
|
|
2019
|
+
"os"
|
|
2020
|
+
"text/tabwriter"
|
|
2021
|
+
|
|
2022
|
+
"github.com/spf13/cobra"
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
func init() {
|
|
2026
|
+
rootCmd.AddCommand(profilesCmd)
|
|
2027
|
+
profilesCmd.AddCommand(profilesListCmd)
|
|
2028
|
+
profilesCmd.AddCommand(profilesRemoveCmd)
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
var profilesCmd = &cobra.Command{
|
|
2032
|
+
Use: "profiles",
|
|
2033
|
+
Short: "Manage LinkedIn profiles",
|
|
2034
|
+
Long: `List, add, or remove LinkedIn profiles.`,
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
var profilesListCmd = &cobra.Command{
|
|
2038
|
+
Use: "list",
|
|
2039
|
+
Short: "List all profiles",
|
|
2040
|
+
Run: runProfilesList,
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
func runProfilesList(cmd *cobra.Command, args []string) {
|
|
2044
|
+
cfg, err := getConfigManager()
|
|
2045
|
+
if err != nil {
|
|
2046
|
+
exitWithError("Failed to initialize config: %v", err)
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
profiles, err := cfg.ListProfiles()
|
|
2050
|
+
if err != nil {
|
|
2051
|
+
exitWithError("Failed to list profiles: %v", err)
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
if len(profiles) == 0 {
|
|
2055
|
+
fmt.Println("No profiles found.")
|
|
2056
|
+
fmt.Println("Use 'linkedin auth --profile <name>' to create one.")
|
|
2057
|
+
return
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// Load rate limits for status
|
|
2061
|
+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
2062
|
+
fmt.Fprintln(w, "NAME\tPINCHTAB PROFILE\tLAST USED")
|
|
2063
|
+
|
|
2064
|
+
for _, name := range profiles {
|
|
2065
|
+
profile, err := cfg.LoadProfile(name)
|
|
2066
|
+
if err != nil {
|
|
2067
|
+
fmt.Fprintf(w, "%s\t<error>\t<error>\n", name)
|
|
2068
|
+
continue
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
lastUsed := "Never"
|
|
2072
|
+
if !profile.LastUsed.IsZero() {
|
|
2073
|
+
lastUsed = profile.LastUsed.Format("2006-01-02 15:04")
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
fmt.Fprintf(w, "%s\t%s\t%s\n", profile.Name, profile.PinchTabProfile, lastUsed)
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
w.Flush()
|
|
2080
|
+
|
|
2081
|
+
// Show rate limits
|
|
2082
|
+
fmt.Println("\nRate Limits:")
|
|
2083
|
+
for _, name := range profiles {
|
|
2084
|
+
limiter := ratelimit.NewLimiter(name, ratelimit.DefaultLimits(), cfg)
|
|
2085
|
+
status, err := limiter.Status()
|
|
2086
|
+
if err != nil {
|
|
2087
|
+
continue
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
fmt.Printf(" %s: %d/%d connections today, %d/%d messages today\n",
|
|
2091
|
+
name,
|
|
2092
|
+
status["connections_today"],
|
|
2093
|
+
status["connections_limit"],
|
|
2094
|
+
status["messages_today"],
|
|
2095
|
+
status["messages_limit"],
|
|
2096
|
+
)
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
var profilesRemoveCmd = &cobra.Command{
|
|
2101
|
+
Use: "remove [name]",
|
|
2102
|
+
Short: "Remove a profile",
|
|
2103
|
+
Args: cobra.ExactArgs(1),
|
|
2104
|
+
Run: runProfilesRemove,
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
func runProfilesRemove(cmd *cobra.Command, args []string) {
|
|
2108
|
+
name := args[0]
|
|
2109
|
+
|
|
2110
|
+
cfg, err := getConfigManager()
|
|
2111
|
+
if err != nil {
|
|
2112
|
+
exitWithError("Failed to initialize config: %v", err)
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
if !cfg.ProfileExists(name) {
|
|
2116
|
+
exitWithError("Profile '%s' not found", name)
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
fmt.Printf("Are you sure you want to remove profile '%s'? (y/N): ", name)
|
|
2120
|
+
var response string
|
|
2121
|
+
fmt.Scanln(&response)
|
|
2122
|
+
|
|
2123
|
+
if response != "y" && response != "Y" {
|
|
2124
|
+
fmt.Println("Cancelled.")
|
|
2125
|
+
return
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
if err := cfg.DeleteProfile(name); err != nil {
|
|
2129
|
+
exitWithError("Failed to remove profile: %v", err)
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
fmt.Printf("✓ Profile '%s' removed.\n", name)
|
|
2133
|
+
fmt.Println("Note: The PinchTab browser profile is still saved. Remove it manually if needed.")
|
|
2134
|
+
}
|
|
2135
|
+
```
|
|
2136
|
+
|
|
2137
|
+
**Step 2: Add import**
|
|
2138
|
+
|
|
2139
|
+
Edit: `internal/cmd/profiles.go`
|
|
2140
|
+
|
|
2141
|
+
Add at imports:
|
|
2142
|
+
```go
|
|
2143
|
+
import (
|
|
2144
|
+
"fmt"
|
|
2145
|
+
"os"
|
|
2146
|
+
"text/tabwriter"
|
|
2147
|
+
|
|
2148
|
+
"github.com/spf13/cobra"
|
|
2149
|
+
"github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
|
|
2150
|
+
)
|
|
2151
|
+
```
|
|
2152
|
+
|
|
2153
|
+
**Step 3: Test**
|
|
2154
|
+
|
|
2155
|
+
```bash
|
|
2156
|
+
go build -o linkedin-cli ./cmd/linkedin
|
|
2157
|
+
./linkedin-cli profiles list
|
|
2158
|
+
./linkedin-cli profiles --help
|
|
2159
|
+
```
|
|
2160
|
+
|
|
2161
|
+
**Step 4: Commit**
|
|
2162
|
+
|
|
2163
|
+
```bash
|
|
2164
|
+
git add internal/cmd/profiles.go
|
|
2165
|
+
git commit -m "feat: add profiles list and remove commands"
|
|
2166
|
+
```
|
|
2167
|
+
|
|
2168
|
+
---
|
|
2169
|
+
|
|
2170
|
+
## Task 8: Final Polish
|
|
2171
|
+
|
|
2172
|
+
**Files:**
|
|
2173
|
+
- Modify: `README.md`
|
|
2174
|
+
- Create: `Makefile`
|
|
2175
|
+
|
|
2176
|
+
**Step 1: Update README**
|
|
2177
|
+
|
|
2178
|
+
Replace: `README.md` content
|
|
2179
|
+
|
|
2180
|
+
```markdown
|
|
2181
|
+
# LinkedIn CLI
|
|
2182
|
+
|
|
2183
|
+
A CLI tool for LinkedIn automation using PinchTab. Send connection requests and messages safely with built-in rate limiting.
|
|
2184
|
+
|
|
2185
|
+
## Prerequisites
|
|
2186
|
+
|
|
2187
|
+
1. **Go 1.21+** - [Install Go](https://go.dev/doc/install)
|
|
2188
|
+
2. **PinchTab** - Install and run:
|
|
2189
|
+
```bash
|
|
2190
|
+
curl -fsSL https://pinchtab.com/install.sh | bash
|
|
2191
|
+
pinchtab # Keep this running in a separate terminal
|
|
2192
|
+
```
|
|
2193
|
+
|
|
2194
|
+
## Installation
|
|
2195
|
+
|
|
2196
|
+
```bash
|
|
2197
|
+
go install github.com/thaddeus-git/linkedin-cli/cmd/linkedin@latest
|
|
2198
|
+
```
|
|
2199
|
+
|
|
2200
|
+
Or build from source:
|
|
2201
|
+
|
|
2202
|
+
```bash
|
|
2203
|
+
git clone https://github.com/thaddeus-git/linkedin-cli.git
|
|
2204
|
+
cd linkedin-cli
|
|
2205
|
+
go build -o linkedin-cli ./cmd/linkedin
|
|
2206
|
+
mv linkedin-cli $GOPATH/bin/linkedin
|
|
2207
|
+
```
|
|
2208
|
+
|
|
2209
|
+
## Quick Start
|
|
2210
|
+
|
|
2211
|
+
### 1. Authenticate your LinkedIn profile
|
|
2212
|
+
|
|
2213
|
+
```bash
|
|
2214
|
+
linkedin auth --profile john
|
|
2215
|
+
```
|
|
2216
|
+
|
|
2217
|
+
This opens a browser window. Log in to LinkedIn manually, then press Enter in the terminal.
|
|
2218
|
+
|
|
2219
|
+
### 2. Send a connection request
|
|
2220
|
+
|
|
2221
|
+
```bash
|
|
2222
|
+
linkedin connect --profile john --url linkedin.com/in/alice
|
|
2223
|
+
```
|
|
2224
|
+
|
|
2225
|
+
With a personal note:
|
|
2226
|
+
|
|
2227
|
+
```bash
|
|
2228
|
+
linkedin connect --profile john --url linkedin.com/in/alice \
|
|
2229
|
+
--message "Hi Alice, saw your post about Go concurrency. Would love to connect!"
|
|
2230
|
+
```
|
|
2231
|
+
|
|
2232
|
+
### 3. Send a direct message
|
|
2233
|
+
|
|
2234
|
+
```bash
|
|
2235
|
+
linkedin message --profile john --url linkedin.com/in/alice \
|
|
2236
|
+
--message "Thanks for connecting! Looking forward to staying in touch."
|
|
2237
|
+
```
|
|
2238
|
+
|
|
2239
|
+
## Commands
|
|
2240
|
+
|
|
2241
|
+
| Command | Description |
|
|
2242
|
+
|---------|-------------|
|
|
2243
|
+
| `linkedin auth` | Authenticate a new LinkedIn profile |
|
|
2244
|
+
| `linkedin connect` | Send a connection request |
|
|
2245
|
+
| `linkedin message` | Send a direct message |
|
|
2246
|
+
| `linkedin profiles list` | List authenticated profiles |
|
|
2247
|
+
| `linkedin profiles remove` | Remove a profile |
|
|
2248
|
+
|
|
2249
|
+
## Configuration
|
|
2250
|
+
|
|
2251
|
+
### Environment Variables
|
|
2252
|
+
|
|
2253
|
+
| Variable | Description | Default |
|
|
2254
|
+
|----------|-------------|---------|
|
|
2255
|
+
| `LINKEDIN_PROFILE` | Default profile to use | - |
|
|
2256
|
+
| `PINCHTAB_HOST` | PinchTab server URL | `http://localhost:9867` |
|
|
2257
|
+
|
|
2258
|
+
### Profile Storage
|
|
2259
|
+
|
|
2260
|
+
Profiles and rate limits are stored in `~/.linkedin-cli/`:
|
|
2261
|
+
|
|
2262
|
+
- `profiles/*.json` - Profile configurations
|
|
2263
|
+
- `ratelimit.json` - Rate limit tracking
|
|
2264
|
+
|
|
2265
|
+
### Rate Limits
|
|
2266
|
+
|
|
2267
|
+
Built-in limits to keep your account safe:
|
|
2268
|
+
|
|
2269
|
+
- **Connection requests**: 20/day, 100/week
|
|
2270
|
+
- **Messages**: 50/day
|
|
2271
|
+
- **Delays**: 3-8 seconds between actions (randomized)
|
|
2272
|
+
|
|
2273
|
+
Use `--verbose` to see rate limit status.
|
|
2274
|
+
|
|
2275
|
+
## Safety Features
|
|
2276
|
+
|
|
2277
|
+
- ✓ Built-in rate limiting
|
|
2278
|
+
- ✓ Human-like delays between actions
|
|
2279
|
+
- ✓ Dry-run mode (`--dry-run`)
|
|
2280
|
+
- ✓ Session persistence (log in once)
|
|
2281
|
+
- ✓ LinkedIn profile URL validation
|
|
2282
|
+
|
|
2283
|
+
## Examples
|
|
2284
|
+
|
|
2285
|
+
### Dry run (test without executing)
|
|
2286
|
+
|
|
2287
|
+
```bash
|
|
2288
|
+
linkedin connect --profile john --url linkedin.com/in/alice --dry-run
|
|
2289
|
+
```
|
|
2290
|
+
|
|
2291
|
+
### Verbose output
|
|
2292
|
+
|
|
2293
|
+
```bash
|
|
2294
|
+
linkedin connect --profile john --url linkedin.com/in/alice --verbose
|
|
2295
|
+
```
|
|
2296
|
+
|
|
2297
|
+
### Use environment variable for profile
|
|
2298
|
+
|
|
2299
|
+
```bash
|
|
2300
|
+
export LINKEDIN_PROFILE=john
|
|
2301
|
+
linkedin connect --url linkedin.com/in/alice
|
|
2302
|
+
```
|
|
2303
|
+
|
|
2304
|
+
## Troubleshooting
|
|
2305
|
+
|
|
2306
|
+
### "Failed to start PinchTab instance"
|
|
2307
|
+
|
|
2308
|
+
Make sure PinchTab is running:
|
|
2309
|
+
|
|
2310
|
+
```bash
|
|
2311
|
+
pinchtab # Run in separate terminal
|
|
2312
|
+
```
|
|
2313
|
+
|
|
2314
|
+
### "Rate limit exceeded"
|
|
2315
|
+
|
|
2316
|
+
Wait until the rate limit resets. Check status with:
|
|
2317
|
+
|
|
2318
|
+
```bash
|
|
2319
|
+
linkedin profiles list
|
|
2320
|
+
```
|
|
2321
|
+
|
|
2322
|
+
### "Profile not found"
|
|
2323
|
+
|
|
2324
|
+
Authenticate first:
|
|
2325
|
+
|
|
2326
|
+
```bash
|
|
2327
|
+
linkedin auth --profile <name>
|
|
2328
|
+
```
|
|
2329
|
+
|
|
2330
|
+
## Disclaimer
|
|
2331
|
+
|
|
2332
|
+
This tool is for educational and personal use. Using automation on LinkedIn may violate their Terms of Service. Use at your own risk. Start with conservative limits and monitor your account.
|
|
2333
|
+
|
|
2334
|
+
## License
|
|
2335
|
+
|
|
2336
|
+
MIT
|
|
2337
|
+
```
|
|
2338
|
+
|
|
2339
|
+
**Step 2: Create Makefile**
|
|
2340
|
+
|
|
2341
|
+
Create: `Makefile`
|
|
2342
|
+
|
|
2343
|
+
```makefile
|
|
2344
|
+
.PHONY: build test clean install lint
|
|
2345
|
+
|
|
2346
|
+
BINARY_NAME=linkedin-cli
|
|
2347
|
+
MAIN_PACKAGE=./cmd/linkedin
|
|
2348
|
+
|
|
2349
|
+
build:
|
|
2350
|
+
go build -o $(BINARY_NAME) $(MAIN_PACKAGE)
|
|
2351
|
+
|
|
2352
|
+
test:
|
|
2353
|
+
go test -v ./...
|
|
2354
|
+
|
|
2355
|
+
clean:
|
|
2356
|
+
rm -f $(BINARY_NAME)
|
|
2357
|
+
go clean
|
|
2358
|
+
|
|
2359
|
+
install: build
|
|
2360
|
+
mv $(BINARY_NAME) $(GOPATH)/bin/linkedin
|
|
2361
|
+
|
|
2362
|
+
dev:
|
|
2363
|
+
go run $(MAIN_PACKAGE)
|
|
2364
|
+
|
|
2365
|
+
lint:
|
|
2366
|
+
golangci-lint run
|
|
2367
|
+
|
|
2368
|
+
fmt:
|
|
2369
|
+
go fmt ./...
|
|
2370
|
+
|
|
2371
|
+
deps:
|
|
2372
|
+
go mod download
|
|
2373
|
+
go mod tidy
|
|
2374
|
+
```
|
|
2375
|
+
|
|
2376
|
+
**Step 3: Final build test**
|
|
2377
|
+
|
|
2378
|
+
```bash
|
|
2379
|
+
make clean
|
|
2380
|
+
make build
|
|
2381
|
+
make test
|
|
2382
|
+
./linkedin-cli --help
|
|
2383
|
+
./linkedin-cli auth --help
|
|
2384
|
+
./linkedin-cli connect --help
|
|
2385
|
+
./linkedin-cli message --help
|
|
2386
|
+
./linkedin-cli profiles --help
|
|
2387
|
+
```
|
|
2388
|
+
|
|
2389
|
+
**Step 4: Final commit**
|
|
2390
|
+
|
|
2391
|
+
```bash
|
|
2392
|
+
git add README.md Makefile
|
|
2393
|
+
git commit -m "docs: add comprehensive README and Makefile"
|
|
2394
|
+
```
|
|
2395
|
+
|
|
2396
|
+
---
|
|
2397
|
+
|
|
2398
|
+
## Summary
|
|
2399
|
+
|
|
2400
|
+
**Completed implementation:**
|
|
2401
|
+
|
|
2402
|
+
1. ✅ PinchTab HTTP client (`internal/pinchtab/`)
|
|
2403
|
+
2. ✅ Configuration management (`internal/config/`)
|
|
2404
|
+
3. ✅ Rate limiting (`internal/ratelimit/`)
|
|
2405
|
+
4. ✅ LinkedIn selectors & navigator (`internal/linkedin/`)
|
|
2406
|
+
5. ✅ CLI commands (`internal/cmd/`)
|
|
2407
|
+
- `auth` - Authenticate profiles
|
|
2408
|
+
- `connect` - Send connection requests
|
|
2409
|
+
- `message` - Send direct messages
|
|
2410
|
+
- `profiles list/remove` - Manage profiles
|
|
2411
|
+
6. ✅ Main entry point (`cmd/linkedin/main.go`)
|
|
2412
|
+
7. ✅ Documentation (README, Makefile)
|
|
2413
|
+
|
|
2414
|
+
**Next steps:**
|
|
2415
|
+
1. Test with real LinkedIn account
|
|
2416
|
+
2. Add `queue` command for batch processing
|
|
2417
|
+
3. Add more robust error handling for LinkedIn UI changes
|
|
2418
|
+
4. Add tests for navigator (requires mocking)
|
|
2419
|
+
|
|
2420
|
+
**Plan saved to:** `docs/plans/2025-03-03-linkedin-cli-implementation.md`
|