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.
Files changed (314) hide show
  1. package/.env.example +12 -0
  2. package/.github/workflows/ci.yml +66 -0
  3. package/.github/workflows/publish.yml +48 -0
  4. package/.husky/pre-commit +6 -0
  5. package/.prettierignore +4 -0
  6. package/.prettierrc +10 -0
  7. package/AGENTS.md +294 -0
  8. package/CHANGELOG.md +40 -0
  9. package/GIT_RELEASE.md +167 -0
  10. package/LICENSE +21 -0
  11. package/Makefile +30 -0
  12. package/NPM_PUBLISHING.md +230 -0
  13. package/PYEOF +0 -0
  14. package/README.md +295 -0
  15. package/TESTING-GUIDE.md +151 -0
  16. package/cmd/linkedin/main.go +9 -0
  17. package/dist/agent/action-executor.d.ts +81 -0
  18. package/dist/agent/action-executor.d.ts.map +1 -0
  19. package/dist/agent/action-executor.js +170 -0
  20. package/dist/agent/action-executor.js.map +1 -0
  21. package/dist/agent/action-executor.test.d.ts +2 -0
  22. package/dist/agent/action-executor.test.d.ts.map +1 -0
  23. package/dist/agent/action-executor.test.js +366 -0
  24. package/dist/agent/action-executor.test.js.map +1 -0
  25. package/dist/agent/claude-client.d.ts +74 -0
  26. package/dist/agent/claude-client.d.ts.map +1 -0
  27. package/dist/agent/claude-client.js +314 -0
  28. package/dist/agent/claude-client.js.map +1 -0
  29. package/dist/agent/claude-client.test.d.ts +2 -0
  30. package/dist/agent/claude-client.test.d.ts.map +1 -0
  31. package/dist/agent/claude-client.test.js +590 -0
  32. package/dist/agent/claude-client.test.js.map +1 -0
  33. package/dist/agent/dom-extractor.d.ts +50 -0
  34. package/dist/agent/dom-extractor.d.ts.map +1 -0
  35. package/dist/agent/dom-extractor.js +374 -0
  36. package/dist/agent/dom-extractor.js.map +1 -0
  37. package/dist/agent/dom-extractor.test.d.ts +7 -0
  38. package/dist/agent/dom-extractor.test.d.ts.map +1 -0
  39. package/dist/agent/dom-extractor.test.js +504 -0
  40. package/dist/agent/dom-extractor.test.js.map +1 -0
  41. package/dist/agent/extension-client.d.ts +75 -0
  42. package/dist/agent/extension-client.d.ts.map +1 -0
  43. package/dist/agent/extension-client.js +245 -0
  44. package/dist/agent/extension-client.js.map +1 -0
  45. package/dist/agent/index.d.ts +8 -0
  46. package/dist/agent/index.d.ts.map +1 -0
  47. package/dist/agent/index.js +16 -0
  48. package/dist/agent/index.js.map +1 -0
  49. package/dist/agent/page-agent.d.ts +76 -0
  50. package/dist/agent/page-agent.d.ts.map +1 -0
  51. package/dist/agent/page-agent.js +236 -0
  52. package/dist/agent/page-agent.js.map +1 -0
  53. package/dist/agent/types.d.ts +236 -0
  54. package/dist/agent/types.d.ts.map +1 -0
  55. package/dist/agent/types.js +37 -0
  56. package/dist/agent/types.js.map +1 -0
  57. package/dist/cli/agent-commands.d.ts +3 -0
  58. package/dist/cli/agent-commands.d.ts.map +1 -0
  59. package/dist/cli/agent-commands.js +250 -0
  60. package/dist/cli/agent-commands.js.map +1 -0
  61. package/dist/cli/auth.d.ts +3 -0
  62. package/dist/cli/auth.d.ts.map +1 -0
  63. package/dist/cli/auth.js +288 -0
  64. package/dist/cli/auth.js.map +1 -0
  65. package/dist/cli/company.d.ts +3 -0
  66. package/dist/cli/company.d.ts.map +1 -0
  67. package/dist/cli/company.js +55 -0
  68. package/dist/cli/company.js.map +1 -0
  69. package/dist/cli/connection.d.ts +3 -0
  70. package/dist/cli/connection.d.ts.map +1 -0
  71. package/dist/cli/connection.js +79 -0
  72. package/dist/cli/connection.js.map +1 -0
  73. package/dist/cli/index.d.ts +7 -0
  74. package/dist/cli/index.d.ts.map +1 -0
  75. package/dist/cli/index.js +17 -0
  76. package/dist/cli/index.js.map +1 -0
  77. package/dist/cli/messages.d.ts +3 -0
  78. package/dist/cli/messages.d.ts.map +1 -0
  79. package/dist/cli/messages.js +268 -0
  80. package/dist/cli/messages.js.map +1 -0
  81. package/dist/cli/profile.d.ts +3 -0
  82. package/dist/cli/profile.d.ts.map +1 -0
  83. package/dist/cli/profile.js +81 -0
  84. package/dist/cli/profile.js.map +1 -0
  85. package/dist/cli/profile.test.d.ts +2 -0
  86. package/dist/cli/profile.test.d.ts.map +1 -0
  87. package/dist/cli/profile.test.js +15 -0
  88. package/dist/cli/profile.test.js.map +1 -0
  89. package/dist/cli/reply.d.ts +3 -0
  90. package/dist/cli/reply.d.ts.map +1 -0
  91. package/dist/cli/reply.js +129 -0
  92. package/dist/cli/reply.js.map +1 -0
  93. package/dist/core/audit.d.ts +17 -0
  94. package/dist/core/audit.d.ts.map +1 -0
  95. package/dist/core/audit.js +121 -0
  96. package/dist/core/audit.js.map +1 -0
  97. package/dist/core/audit.test.d.ts +2 -0
  98. package/dist/core/audit.test.d.ts.map +1 -0
  99. package/dist/core/audit.test.js +142 -0
  100. package/dist/core/audit.test.js.map +1 -0
  101. package/dist/core/browser-cookies.d.ts +19 -0
  102. package/dist/core/browser-cookies.d.ts.map +1 -0
  103. package/dist/core/browser-cookies.js +181 -0
  104. package/dist/core/browser-cookies.js.map +1 -0
  105. package/dist/core/browser.d.ts +50 -0
  106. package/dist/core/browser.d.ts.map +1 -0
  107. package/dist/core/browser.js +318 -0
  108. package/dist/core/browser.js.map +1 -0
  109. package/dist/core/config.d.ts +20 -0
  110. package/dist/core/config.d.ts.map +1 -0
  111. package/dist/core/config.js +103 -0
  112. package/dist/core/config.js.map +1 -0
  113. package/dist/core/config.test.d.ts +2 -0
  114. package/dist/core/config.test.d.ts.map +1 -0
  115. package/dist/core/config.test.js +111 -0
  116. package/dist/core/config.test.js.map +1 -0
  117. package/dist/core/storage.d.ts +19 -0
  118. package/dist/core/storage.d.ts.map +1 -0
  119. package/dist/core/storage.js +124 -0
  120. package/dist/core/storage.js.map +1 -0
  121. package/dist/core/storage.test.d.ts +2 -0
  122. package/dist/core/storage.test.d.ts.map +1 -0
  123. package/dist/core/storage.test.js +142 -0
  124. package/dist/core/storage.test.js.map +1 -0
  125. package/dist/index.d.ts +3 -0
  126. package/dist/index.d.ts.map +1 -0
  127. package/dist/index.js +63 -0
  128. package/dist/index.js.map +1 -0
  129. package/dist/linkedin/auth.d.ts +22 -0
  130. package/dist/linkedin/auth.d.ts.map +1 -0
  131. package/dist/linkedin/auth.js +167 -0
  132. package/dist/linkedin/auth.js.map +1 -0
  133. package/dist/linkedin/company-extractor.d.ts +36 -0
  134. package/dist/linkedin/company-extractor.d.ts.map +1 -0
  135. package/dist/linkedin/company-extractor.js +211 -0
  136. package/dist/linkedin/company-extractor.js.map +1 -0
  137. package/dist/linkedin/company-extractor.test.d.ts +2 -0
  138. package/dist/linkedin/company-extractor.test.d.ts.map +1 -0
  139. package/dist/linkedin/company-extractor.test.js +52 -0
  140. package/dist/linkedin/company-extractor.test.js.map +1 -0
  141. package/dist/linkedin/connector.d.ts +45 -0
  142. package/dist/linkedin/connector.d.ts.map +1 -0
  143. package/dist/linkedin/connector.js +245 -0
  144. package/dist/linkedin/connector.js.map +1 -0
  145. package/dist/linkedin/message-sender.d.ts +32 -0
  146. package/dist/linkedin/message-sender.d.ts.map +1 -0
  147. package/dist/linkedin/message-sender.js +112 -0
  148. package/dist/linkedin/message-sender.js.map +1 -0
  149. package/dist/linkedin/messages.d.ts +78 -0
  150. package/dist/linkedin/messages.d.ts.map +1 -0
  151. package/dist/linkedin/messages.js +745 -0
  152. package/dist/linkedin/messages.js.map +1 -0
  153. package/dist/linkedin/profile.d.ts +37 -0
  154. package/dist/linkedin/profile.d.ts.map +1 -0
  155. package/dist/linkedin/profile.js +268 -0
  156. package/dist/linkedin/profile.js.map +1 -0
  157. package/dist/linkedin/profile.test.d.ts +2 -0
  158. package/dist/linkedin/profile.test.d.ts.map +1 -0
  159. package/dist/linkedin/profile.test.js +68 -0
  160. package/dist/linkedin/profile.test.js.map +1 -0
  161. package/dist/linkedin/reply.d.ts +21 -0
  162. package/dist/linkedin/reply.d.ts.map +1 -0
  163. package/dist/linkedin/reply.js +76 -0
  164. package/dist/linkedin/reply.js.map +1 -0
  165. package/dist/linkedin/selector-engine.d.ts +69 -0
  166. package/dist/linkedin/selector-engine.d.ts.map +1 -0
  167. package/dist/linkedin/selector-engine.js +339 -0
  168. package/dist/linkedin/selector-engine.js.map +1 -0
  169. package/dist/linkedin/selector-engine.test.d.ts +2 -0
  170. package/dist/linkedin/selector-engine.test.d.ts.map +1 -0
  171. package/dist/linkedin/selector-engine.test.js +135 -0
  172. package/dist/linkedin/selector-engine.test.js.map +1 -0
  173. package/dist/linkedin/selectors.d.ts +65 -0
  174. package/dist/linkedin/selectors.d.ts.map +1 -0
  175. package/dist/linkedin/selectors.js +261 -0
  176. package/dist/linkedin/selectors.js.map +1 -0
  177. package/dist/templates/engine.d.ts +37 -0
  178. package/dist/templates/engine.d.ts.map +1 -0
  179. package/dist/templates/engine.js +215 -0
  180. package/dist/templates/engine.js.map +1 -0
  181. package/dist/templates/engine.test.d.ts +2 -0
  182. package/dist/templates/engine.test.d.ts.map +1 -0
  183. package/dist/templates/engine.test.js +212 -0
  184. package/dist/templates/engine.test.js.map +1 -0
  185. package/dist/templates/index.d.ts +2 -0
  186. package/dist/templates/index.d.ts.map +1 -0
  187. package/dist/templates/index.js +7 -0
  188. package/dist/templates/index.js.map +1 -0
  189. package/dist/types/index.d.ts +113 -0
  190. package/dist/types/index.d.ts.map +1 -0
  191. package/dist/types/index.js +3 -0
  192. package/dist/types/index.js.map +1 -0
  193. package/dist/types/index.test.d.ts +2 -0
  194. package/dist/types/index.test.d.ts.map +1 -0
  195. package/dist/types/index.test.js +90 -0
  196. package/dist/types/index.test.js.map +1 -0
  197. package/dist/utils/paths.d.ts +8 -0
  198. package/dist/utils/paths.d.ts.map +1 -0
  199. package/dist/utils/paths.js +68 -0
  200. package/dist/utils/paths.js.map +1 -0
  201. package/dist/utils/rate-limiter.d.ts +22 -0
  202. package/dist/utils/rate-limiter.d.ts.map +1 -0
  203. package/dist/utils/rate-limiter.js +57 -0
  204. package/dist/utils/rate-limiter.js.map +1 -0
  205. package/dist/utils/retry.d.ts +18 -0
  206. package/dist/utils/retry.d.ts.map +1 -0
  207. package/dist/utils/retry.js +49 -0
  208. package/dist/utils/retry.js.map +1 -0
  209. package/docs/connection-command.md +52 -0
  210. package/docs/plans/2025-03-03-linkedin-cli-design.md +280 -0
  211. package/docs/plans/2025-03-03-linkedin-cli-implementation-plan.md +2087 -0
  212. package/docs/plans/2025-03-03-linkedin-cli-implementation.md +2420 -0
  213. package/docs/plans/2026-02-19-linkedin-connection-feature.md +596 -0
  214. package/docs/plans/2026-02-28-messages-send-feature.md +480 -0
  215. package/docs/plans/2026-02-28-messages-show-design.md +243 -0
  216. package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-design.md +394 -0
  217. package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-plan.md +1592 -0
  218. package/docs/superpowers/plans/2026-03-13-linkedin-automation-resilience-migration.md +425 -0
  219. package/docs/superpowers/plans/2026-03-13-playwright-fara-migration.md +1112 -0
  220. package/docs/superpowers/plans/2026-03-14-page-agent-plan.md +1598 -0
  221. package/docs/superpowers/plans/2026-03-15-company-profile-extraction.md +591 -0
  222. package/docs/superpowers/plans/2026-03-15-profile-extraction-plan.md +943 -0
  223. package/docs/superpowers/specs/2026-03-14-company-profile-extraction-design.md +371 -0
  224. package/docs/superpowers/specs/2026-03-14-page-agent-design.md +385 -0
  225. package/docs/superpowers/specs/2026-03-15-profile-extraction-design.md +409 -0
  226. package/eslint.config.mjs +58 -0
  227. package/go.mod +9 -0
  228. package/go.sum +10 -0
  229. package/import-cookies.js +376 -0
  230. package/internal/cmd/actions.go +123 -0
  231. package/internal/cmd/auth.go +108 -0
  232. package/internal/cmd/connect.go +42 -0
  233. package/internal/cmd/message.go +44 -0
  234. package/internal/cmd/people.go +454 -0
  235. package/internal/cmd/profiles.go +121 -0
  236. package/internal/cmd/root.go +89 -0
  237. package/internal/cmd/sequence.go +192 -0
  238. package/internal/config/config.go +187 -0
  239. package/internal/config/config_test.go +121 -0
  240. package/internal/config/profile.go +65 -0
  241. package/internal/linkedin/navigator.go +195 -0
  242. package/internal/linkedin/selectors.go +39 -0
  243. package/internal/linkedin/validator.go +69 -0
  244. package/internal/pinchtab/client.go +183 -0
  245. package/internal/pinchtab/client_test.go +67 -0
  246. package/internal/pinchtab/types.go +50 -0
  247. package/internal/ratelimit/limiter.go +115 -0
  248. package/internal/ratelimit/limits.go +32 -0
  249. package/package.json +67 -0
  250. package/release.sh +66 -0
  251. package/scripts/debug-linkedin.js +156 -0
  252. package/scripts/debug-login.js +193 -0
  253. package/scripts/extract-from-edge.js +96 -0
  254. package/scripts/import-cookies.js +101 -0
  255. package/scripts/poc-show-data.js +205 -0
  256. package/scripts/proof-of-access.js +87 -0
  257. package/scripts/prove-connection.js +110 -0
  258. package/scripts/show-linkedin-data.js +173 -0
  259. package/src/agent/action-executor.test.ts +464 -0
  260. package/src/agent/action-executor.ts +203 -0
  261. package/src/agent/claude-client.test.ts +707 -0
  262. package/src/agent/claude-client.ts +422 -0
  263. package/src/agent/dom-extractor.test.ts +574 -0
  264. package/src/agent/dom-extractor.ts +437 -0
  265. package/src/agent/extension-client.ts +306 -0
  266. package/src/agent/index.ts +28 -0
  267. package/src/agent/page-agent.ts +292 -0
  268. package/src/agent/types.ts +288 -0
  269. package/src/cli/agent-commands.ts +274 -0
  270. package/src/cli/auth.ts +343 -0
  271. package/src/cli/company.ts +66 -0
  272. package/src/cli/connection.ts +89 -0
  273. package/src/cli/index.ts +7 -0
  274. package/src/cli/messages.ts +338 -0
  275. package/src/cli/profile.test.ts +14 -0
  276. package/src/cli/profile.ts +95 -0
  277. package/src/cli/reply.ts +110 -0
  278. package/src/core/audit.test.ts +134 -0
  279. package/src/core/audit.ts +98 -0
  280. package/src/core/browser-cookies.ts +203 -0
  281. package/src/core/browser.ts +304 -0
  282. package/src/core/config.test.ts +90 -0
  283. package/src/core/config.ts +81 -0
  284. package/src/core/storage.test.ts +129 -0
  285. package/src/core/storage.ts +100 -0
  286. package/src/index.ts +70 -0
  287. package/src/linkedin/auth.ts +218 -0
  288. package/src/linkedin/company-extractor.test.ts +58 -0
  289. package/src/linkedin/company-extractor.ts +222 -0
  290. package/src/linkedin/connector.ts +336 -0
  291. package/src/linkedin/message-sender.ts +141 -0
  292. package/src/linkedin/messages.ts +894 -0
  293. package/src/linkedin/profile.test.ts +79 -0
  294. package/src/linkedin/profile.ts +314 -0
  295. package/src/linkedin/reply.ts +96 -0
  296. package/src/linkedin/selector-engine.test.ts +167 -0
  297. package/src/linkedin/selector-engine.ts +393 -0
  298. package/src/linkedin/selectors.ts +268 -0
  299. package/src/templates/defaults/followup.txt +14 -0
  300. package/src/templates/defaults/meeting.txt +16 -0
  301. package/src/templates/defaults/welcome.txt +14 -0
  302. package/src/templates/engine.test.ts +228 -0
  303. package/src/templates/engine.ts +208 -0
  304. package/src/templates/index.ts +1 -0
  305. package/src/types/index.test.ts +94 -0
  306. package/src/types/index.ts +143 -0
  307. package/src/types/sql.js.d.ts +23 -0
  308. package/src/utils/paths.ts +33 -0
  309. package/src/utils/rate-limiter.ts +75 -0
  310. package/src/utils/retry.ts +78 -0
  311. package/test-cli.sh +85 -0
  312. package/test-real-data.sh +97 -0
  313. package/tsconfig.json +23 -0
  314. 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
+ }