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,195 @@
|
|
|
1
|
+
package linkedin
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Navigator handles LinkedIn page interactions
|
|
12
|
+
type Navigator struct {
|
|
13
|
+
client *pinchtab.Client
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// NewNavigator creates a new navigator
|
|
17
|
+
func NewNavigator(client *pinchtab.Client) *Navigator {
|
|
18
|
+
return &Navigator{client: client}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// NavigateToProfile navigates to a LinkedIn profile
|
|
22
|
+
func (n *Navigator) NavigateToProfile(profileURL string) error {
|
|
23
|
+
if !strings.HasPrefix(profileURL, "http") {
|
|
24
|
+
profileURL = "https://linkedin.com/in/" + strings.TrimPrefix(profileURL, "/in/")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if err := n.client.Navigate(profileURL); err != nil {
|
|
28
|
+
return fmt.Errorf("failed to navigate: %w", err)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
time.Sleep(3 * time.Second)
|
|
32
|
+
return nil
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// FindConnectButton finds the connect button in the snapshot
|
|
36
|
+
func (n *Navigator) FindConnectButton(snapshot *pinchtab.Snapshot) (*pinchtab.Node, error) {
|
|
37
|
+
for i := range snapshot.Nodes {
|
|
38
|
+
node := &snapshot.Nodes[i]
|
|
39
|
+
if node.Role == "button" && strings.Contains(strings.ToLower(node.Name), "connect") {
|
|
40
|
+
return node, nil
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return nil, fmt.Errorf("connect button not found")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// FindMessageButton finds the message button in the snapshot
|
|
48
|
+
func (n *Navigator) FindMessageButton(snapshot *pinchtab.Snapshot) (*pinchtab.Node, error) {
|
|
49
|
+
for i := range snapshot.Nodes {
|
|
50
|
+
node := &snapshot.Nodes[i]
|
|
51
|
+
if node.Role == "button" && strings.Contains(strings.ToLower(node.Name), "message") {
|
|
52
|
+
return node, nil
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return nil, fmt.Errorf("message button not found")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ClickConnect clicks the connect button
|
|
60
|
+
func (n *Navigator) ClickConnect() error {
|
|
61
|
+
snapshot, err := n.client.GetSnapshot("interactive")
|
|
62
|
+
if err != nil {
|
|
63
|
+
return fmt.Errorf("failed to get snapshot: %w", err)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
button, err := n.FindConnectButton(snapshot)
|
|
67
|
+
if err != nil {
|
|
68
|
+
return err
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if err := n.client.HumanClick(button.Ref); err != nil {
|
|
72
|
+
return fmt.Errorf("failed to click connect: %w", err)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
time.Sleep(2 * time.Second)
|
|
76
|
+
return nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// SendConnectionRequest sends a connection request with optional note
|
|
80
|
+
func (n *Navigator) SendConnectionRequest(note string) error {
|
|
81
|
+
if note != "" {
|
|
82
|
+
snapshot, err := n.client.GetSnapshot("interactive")
|
|
83
|
+
if err != nil {
|
|
84
|
+
return fmt.Errorf("failed to get modal snapshot: %w", err)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
var textarea *pinchtab.Node
|
|
88
|
+
for i := range snapshot.Nodes {
|
|
89
|
+
node := &snapshot.Nodes[i]
|
|
90
|
+
if node.Role == "textbox" || strings.Contains(strings.ToLower(node.Name), "message") {
|
|
91
|
+
textarea = node
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if textarea != nil {
|
|
97
|
+
if err := n.client.HumanType(textarea.Ref, note); err != nil {
|
|
98
|
+
return fmt.Errorf("failed to type note: %w", err)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
snapshot, err := n.client.GetSnapshot("interactive")
|
|
104
|
+
if err != nil {
|
|
105
|
+
return fmt.Errorf("failed to get snapshot for send: %w", err)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
var sendButton *pinchtab.Node
|
|
109
|
+
for i := range snapshot.Nodes {
|
|
110
|
+
node := &snapshot.Nodes[i]
|
|
111
|
+
if node.Role == "button" && strings.Contains(strings.ToLower(node.Name), "send") {
|
|
112
|
+
sendButton = node
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if sendButton == nil {
|
|
118
|
+
return fmt.Errorf("send button not found")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if err := n.client.HumanClick(sendButton.Ref); err != nil {
|
|
122
|
+
return fmt.Errorf("failed to click send: %w", err)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return nil
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// OpenMessageModal opens the message modal
|
|
129
|
+
func (n *Navigator) OpenMessageModal() error {
|
|
130
|
+
snapshot, err := n.client.GetSnapshot("interactive")
|
|
131
|
+
if err != nil {
|
|
132
|
+
return fmt.Errorf("failed to get snapshot: %w", err)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
button, err := n.FindMessageButton(snapshot)
|
|
136
|
+
if err != nil {
|
|
137
|
+
return err
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if err := n.client.HumanClick(button.Ref); err != nil {
|
|
141
|
+
return fmt.Errorf("failed to click message button: %w", err)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
time.Sleep(2 * time.Second)
|
|
145
|
+
return nil
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// SendMessage sends a direct message
|
|
149
|
+
func (n *Navigator) SendMessage(message string) error {
|
|
150
|
+
snapshot, err := n.client.GetSnapshot("interactive")
|
|
151
|
+
if err != nil {
|
|
152
|
+
return fmt.Errorf("failed to get snapshot: %w", err)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
var textarea *pinchtab.Node
|
|
156
|
+
for i := range snapshot.Nodes {
|
|
157
|
+
node := &snapshot.Nodes[i]
|
|
158
|
+
if node.Role == "textbox" {
|
|
159
|
+
textarea = node
|
|
160
|
+
break
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if textarea == nil {
|
|
165
|
+
return fmt.Errorf("message textarea not found")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if err := n.client.HumanType(textarea.Ref, message); err != nil {
|
|
169
|
+
return fmt.Errorf("failed to type message: %w", err)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
snapshot, err = n.client.GetSnapshot("interactive")
|
|
173
|
+
if err != nil {
|
|
174
|
+
return fmt.Errorf("failed to get snapshot for send: %w", err)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
var sendButton *pinchtab.Node
|
|
178
|
+
for i := range snapshot.Nodes {
|
|
179
|
+
node := &snapshot.Nodes[i]
|
|
180
|
+
if node.Role == "button" && strings.Contains(strings.ToLower(node.Name), "send") {
|
|
181
|
+
sendButton = node
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if sendButton == nil {
|
|
187
|
+
return fmt.Errorf("send button not found")
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if err := n.client.HumanClick(sendButton.Ref); err != nil {
|
|
191
|
+
return fmt.Errorf("failed to click send: %w", err)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return nil
|
|
195
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package linkedin
|
|
2
|
+
|
|
3
|
+
// Selectors contains LinkedIn DOM selectors
|
|
4
|
+
// Note: These are fragile and may need updating as LinkedIn changes
|
|
5
|
+
var Selectors = struct {
|
|
6
|
+
// Connection buttons
|
|
7
|
+
ConnectButton string
|
|
8
|
+
ConnectButtonAlt string
|
|
9
|
+
|
|
10
|
+
// Connection modal
|
|
11
|
+
ConnectModalTextarea string
|
|
12
|
+
ConnectModalSend string
|
|
13
|
+
ConnectModalCancel string
|
|
14
|
+
|
|
15
|
+
// Messaging
|
|
16
|
+
MessageButton string
|
|
17
|
+
MessageTextarea string
|
|
18
|
+
MessageSendButton string
|
|
19
|
+
|
|
20
|
+
// Navigation
|
|
21
|
+
ProfileName string
|
|
22
|
+
ProfileHeadline string
|
|
23
|
+
ProfileCompany string
|
|
24
|
+
}{
|
|
25
|
+
ConnectButton: "button[aria-label*='Connect']",
|
|
26
|
+
ConnectButtonAlt: "button:has-text('Connect')",
|
|
27
|
+
|
|
28
|
+
ConnectModalTextarea: "textarea[name='message']",
|
|
29
|
+
ConnectModalSend: "button[aria-label='Send now']",
|
|
30
|
+
ConnectModalCancel: "button[aria-label='Dismiss']",
|
|
31
|
+
|
|
32
|
+
MessageButton: "button[aria-label*='Message']",
|
|
33
|
+
MessageTextarea: "div[role='textbox']",
|
|
34
|
+
MessageSendButton: "button[type='submit']",
|
|
35
|
+
|
|
36
|
+
ProfileName: "h1",
|
|
37
|
+
ProfileHeadline: "div.text-body-medium",
|
|
38
|
+
ProfileCompany: "a[href*='/company/']",
|
|
39
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package linkedin
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"net/url"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// ValidateProfileURL validates a LinkedIn profile URL
|
|
10
|
+
func ValidateProfileURL(input string) (string, error) {
|
|
11
|
+
// Normalize input
|
|
12
|
+
input = strings.TrimSpace(input)
|
|
13
|
+
|
|
14
|
+
// Check for empty input
|
|
15
|
+
if input == "" {
|
|
16
|
+
return "", fmt.Errorf("URL cannot be empty")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Check if it's already a full URL
|
|
20
|
+
if strings.HasPrefix(input, "http") {
|
|
21
|
+
u, err := url.Parse(input)
|
|
22
|
+
if err != nil {
|
|
23
|
+
return "", fmt.Errorf("invalid URL: %w", err)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if !strings.Contains(u.Host, "linkedin.com") {
|
|
27
|
+
return "", fmt.Errorf("URL must be from linkedin.com")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if !strings.Contains(u.Path, "/in/") {
|
|
31
|
+
return "", fmt.Errorf("URL must be a LinkedIn profile (contains /in/)")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return input, nil
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Handle vanity URL format (linkedin.com/in/username or /in/username)
|
|
38
|
+
if strings.HasPrefix(input, "linkedin.com/in/") || strings.HasPrefix(input, "/in/") {
|
|
39
|
+
username := strings.TrimPrefix(input, "linkedin.com/in/")
|
|
40
|
+
username = strings.TrimPrefix(username, "/in/")
|
|
41
|
+
username = strings.TrimSuffix(username, "/")
|
|
42
|
+
|
|
43
|
+
if username == "" {
|
|
44
|
+
return "", fmt.Errorf("invalid LinkedIn profile URL")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return fmt.Sprintf("https://linkedin.com/in/%s", username), nil
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Assume it's just a username
|
|
51
|
+
return fmt.Sprintf("https://linkedin.com/in/%s", input), nil
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ExtractProfileUsername extracts the username from a LinkedIn profile URL
|
|
55
|
+
func ExtractProfileUsername(profileURL string) string {
|
|
56
|
+
u, err := url.Parse(profileURL)
|
|
57
|
+
if err != nil {
|
|
58
|
+
return ""
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
parts := strings.Split(u.Path, "/")
|
|
62
|
+
for i, part := range parts {
|
|
63
|
+
if part == "in" && i+1 < len(parts) {
|
|
64
|
+
return parts[i+1]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return ""
|
|
69
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
package pinchtab
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"time"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Client wraps the PinchTab HTTP API
|
|
13
|
+
type Client struct {
|
|
14
|
+
baseURL string
|
|
15
|
+
client *http.Client
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// NewClient creates a new PinchTab client
|
|
19
|
+
func NewClient(baseURL string) *Client {
|
|
20
|
+
if baseURL == "" {
|
|
21
|
+
baseURL = "http://localhost:9867"
|
|
22
|
+
}
|
|
23
|
+
return &Client{
|
|
24
|
+
baseURL: baseURL,
|
|
25
|
+
client: &http.Client{
|
|
26
|
+
Timeout: 30 * time.Second,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Navigate navigates the current tab to a URL
|
|
32
|
+
func (c *Client) Navigate(url string) error {
|
|
33
|
+
reqBody := map[string]string{
|
|
34
|
+
"url": url,
|
|
35
|
+
}
|
|
36
|
+
return c.post("/navigate", reqBody, nil)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// GetSnapshot gets the accessibility tree snapshot
|
|
40
|
+
func (c *Client) GetSnapshot(filter string) (*Snapshot, error) {
|
|
41
|
+
url := "/snapshot"
|
|
42
|
+
if filter != "" {
|
|
43
|
+
url += "?filter=" + filter
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
var snapshot Snapshot
|
|
47
|
+
err := c.get(url, &snapshot)
|
|
48
|
+
if err != nil {
|
|
49
|
+
return nil, fmt.Errorf("failed to get snapshot: %w", err)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return &snapshot, nil
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// GetText extracts text from the current tab
|
|
56
|
+
func (c *Client) GetText() (*TextResponse, error) {
|
|
57
|
+
var resp TextResponse
|
|
58
|
+
err := c.get("/text", &resp)
|
|
59
|
+
if err != nil {
|
|
60
|
+
return nil, fmt.Errorf("failed to get text: %w", err)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return &resp, nil
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Click clicks an element
|
|
67
|
+
func (c *Client) Click(ref string) error {
|
|
68
|
+
req := ActionRequest{
|
|
69
|
+
Kind: "click",
|
|
70
|
+
Ref: ref,
|
|
71
|
+
}
|
|
72
|
+
return c.post("/action", req, nil)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// HumanClick clicks with human-like randomization
|
|
76
|
+
func (c *Client) HumanClick(ref string) error {
|
|
77
|
+
req := ActionRequest{
|
|
78
|
+
Kind: "humanClick",
|
|
79
|
+
Ref: ref,
|
|
80
|
+
}
|
|
81
|
+
return c.post("/action", req, nil)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Fill fills an input field
|
|
85
|
+
func (c *Client) Fill(ref string, text string) error {
|
|
86
|
+
req := ActionRequest{
|
|
87
|
+
Kind: "fill",
|
|
88
|
+
Ref: ref,
|
|
89
|
+
Text: text,
|
|
90
|
+
}
|
|
91
|
+
return c.post("/action", req, nil)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// HumanType types with human-like delays
|
|
95
|
+
func (c *Client) HumanType(ref string, text string) error {
|
|
96
|
+
req := ActionRequest{
|
|
97
|
+
Kind: "type",
|
|
98
|
+
Ref: ref,
|
|
99
|
+
Text: text,
|
|
100
|
+
}
|
|
101
|
+
return c.post("/action", req, nil)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Execute runs JavaScript in the current tab
|
|
105
|
+
func (c *Client) Execute(expression string) (interface{}, error) {
|
|
106
|
+
req := map[string]string{
|
|
107
|
+
"expression": expression,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
var resp struct {
|
|
111
|
+
Result interface{} `json:"result"`
|
|
112
|
+
Error string `json:"error,omitempty"`
|
|
113
|
+
}
|
|
114
|
+
err := c.post("/evaluate", req, &resp)
|
|
115
|
+
if err != nil {
|
|
116
|
+
return nil, fmt.Errorf("failed to execute script: %w", err)
|
|
117
|
+
}
|
|
118
|
+
if resp.Error != "" {
|
|
119
|
+
return nil, fmt.Errorf("evaluate error: %s", resp.Error)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return resp.Result, nil
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// post makes a POST request
|
|
126
|
+
func (c *Client) post(path string, body interface{}, result interface{}) error {
|
|
127
|
+
var bodyReader io.Reader
|
|
128
|
+
if body != nil {
|
|
129
|
+
jsonBody, err := json.Marshal(body)
|
|
130
|
+
if err != nil {
|
|
131
|
+
return fmt.Errorf("failed to marshal request: %w", err)
|
|
132
|
+
}
|
|
133
|
+
bodyReader = bytes.NewReader(jsonBody)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
req, err := http.NewRequest("POST", c.baseURL+path, bodyReader)
|
|
137
|
+
if err != nil {
|
|
138
|
+
return fmt.Errorf("failed to create request: %w", err)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if body != nil {
|
|
142
|
+
req.Header.Set("Content-Type", "application/json")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
resp, err := c.client.Do(req)
|
|
146
|
+
if err != nil {
|
|
147
|
+
return fmt.Errorf("request failed: %w", err)
|
|
148
|
+
}
|
|
149
|
+
defer resp.Body.Close()
|
|
150
|
+
|
|
151
|
+
if resp.StatusCode >= 400 {
|
|
152
|
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
153
|
+
return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if result != nil {
|
|
157
|
+
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
|
158
|
+
return fmt.Errorf("failed to decode response: %w", err)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return nil
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// get makes a GET request
|
|
166
|
+
func (c *Client) get(path string, result interface{}) error {
|
|
167
|
+
resp, err := c.client.Get(c.baseURL + path)
|
|
168
|
+
if err != nil {
|
|
169
|
+
return fmt.Errorf("request failed: %w", err)
|
|
170
|
+
}
|
|
171
|
+
defer resp.Body.Close()
|
|
172
|
+
|
|
173
|
+
if resp.StatusCode >= 400 {
|
|
174
|
+
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
175
|
+
return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
|
179
|
+
return fmt.Errorf("failed to decode response: %w", err)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return nil
|
|
183
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
package pinchtab
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestNewClient(t *testing.T) {
|
|
11
|
+
client := NewClient("")
|
|
12
|
+
if client.baseURL != "http://localhost:9867" {
|
|
13
|
+
t.Errorf("expected default URL, got %s", client.baseURL)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
client = NewClient("http://custom:8080")
|
|
17
|
+
if client.baseURL != "http://custom:8080" {
|
|
18
|
+
t.Errorf("expected custom URL, got %s", client.baseURL)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func TestGetSnapshot(t *testing.T) {
|
|
23
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
24
|
+
if r.URL.Path != "/snapshot" {
|
|
25
|
+
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
snapshot := Snapshot{
|
|
29
|
+
URL: "https://linkedin.com",
|
|
30
|
+
Title: "LinkedIn",
|
|
31
|
+
Count: 1,
|
|
32
|
+
Nodes: []Node{
|
|
33
|
+
{Ref: "e0", Role: "button", Name: "Connect", Depth: 0, NodeID: 1},
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
json.NewEncoder(w).Encode(snapshot)
|
|
37
|
+
}))
|
|
38
|
+
defer server.Close()
|
|
39
|
+
|
|
40
|
+
client := NewClient(server.URL)
|
|
41
|
+
snapshot, err := client.GetSnapshot("")
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatalf("unexpected error: %v", err)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if snapshot.Title != "LinkedIn" {
|
|
47
|
+
t.Errorf("expected title 'LinkedIn', got %s", snapshot.Title)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if len(snapshot.Nodes) != 1 {
|
|
51
|
+
t.Errorf("expected 1 node, got %d", len(snapshot.Nodes))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestGetSnapshot_Error(t *testing.T) {
|
|
56
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
57
|
+
w.WriteHeader(http.StatusInternalServerError)
|
|
58
|
+
w.Write([]byte(`{"error": "tab not found"}`))
|
|
59
|
+
}))
|
|
60
|
+
defer server.Close()
|
|
61
|
+
|
|
62
|
+
client := NewClient(server.URL)
|
|
63
|
+
_, err := client.GetSnapshot("")
|
|
64
|
+
if err == nil {
|
|
65
|
+
t.Error("expected error, got nil")
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package pinchtab
|
|
2
|
+
|
|
3
|
+
// Tab represents a browser tab
|
|
4
|
+
type Tab struct {
|
|
5
|
+
ID string `json:"id"`
|
|
6
|
+
URL string `json:"url"`
|
|
7
|
+
Title string `json:"title"`
|
|
8
|
+
Type string `json:"type"`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// NavigateRequest represents a navigation action
|
|
12
|
+
type NavigateRequest struct {
|
|
13
|
+
URL string `json:"url"`
|
|
14
|
+
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
|
15
|
+
BlockImages bool `json:"blockImages,omitempty"`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ActionRequest represents a browser action
|
|
19
|
+
type ActionRequest struct {
|
|
20
|
+
Kind string `json:"kind"`
|
|
21
|
+
Ref string `json:"ref,omitempty"`
|
|
22
|
+
Selector string `json:"selector,omitempty"`
|
|
23
|
+
Text string `json:"text,omitempty"`
|
|
24
|
+
Value interface{} `json:"value,omitempty"`
|
|
25
|
+
Key string `json:"key,omitempty"`
|
|
26
|
+
Direction string `json:"direction,omitempty"`
|
|
27
|
+
Amount int `json:"amount,omitempty"`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Snapshot represents the page accessibility tree
|
|
31
|
+
type Snapshot struct {
|
|
32
|
+
URL string `json:"url"`
|
|
33
|
+
Title string `json:"title"`
|
|
34
|
+
Count int `json:"count"`
|
|
35
|
+
Nodes []Node `json:"nodes"`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Node represents an accessibility tree node
|
|
39
|
+
type Node struct {
|
|
40
|
+
Ref string `json:"ref"`
|
|
41
|
+
Role string `json:"role"`
|
|
42
|
+
Name string `json:"name"`
|
|
43
|
+
Depth int `json:"depth"`
|
|
44
|
+
NodeID int `json:"nodeId"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// TextResponse represents extracted text
|
|
48
|
+
type TextResponse struct {
|
|
49
|
+
Text string `json:"text"`
|
|
50
|
+
}
|