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,454 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/csv"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/url"
|
|
8
|
+
"os"
|
|
9
|
+
"strings"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/spf13/cobra"
|
|
13
|
+
"github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
type personCandidate struct {
|
|
17
|
+
Name string `json:"name"`
|
|
18
|
+
Title string `json:"title"`
|
|
19
|
+
SourceView string `json:"source_view"`
|
|
20
|
+
DiscoveryURL string `json:"discovery_url"`
|
|
21
|
+
PublicProfile string `json:"public_profile_url"`
|
|
22
|
+
ResolutionNote string `json:"resolution_note,omitempty"`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
var (
|
|
26
|
+
peopleCompanyURL string
|
|
27
|
+
peopleMode string
|
|
28
|
+
peopleLimit int
|
|
29
|
+
peopleFormat string
|
|
30
|
+
peopleOutput string
|
|
31
|
+
peopleResolveProfiles bool
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
func init() {
|
|
35
|
+
rootCmd.AddCommand(peopleCmd)
|
|
36
|
+
peopleCmd.Flags().StringVar(
|
|
37
|
+
&peopleCompanyURL,
|
|
38
|
+
"company-url",
|
|
39
|
+
"",
|
|
40
|
+
"LinkedIn company or Sales Navigator company URL",
|
|
41
|
+
)
|
|
42
|
+
peopleCmd.Flags().StringVar(
|
|
43
|
+
&peopleMode,
|
|
44
|
+
"mode",
|
|
45
|
+
"both",
|
|
46
|
+
"Discovery mode: employees|key-persons|both",
|
|
47
|
+
)
|
|
48
|
+
peopleCmd.Flags().IntVar(&peopleLimit, "limit", 10, "Maximum people to return")
|
|
49
|
+
peopleCmd.Flags().StringVar(
|
|
50
|
+
&peopleFormat,
|
|
51
|
+
"format",
|
|
52
|
+
"json",
|
|
53
|
+
"Output format: json|table|csv",
|
|
54
|
+
)
|
|
55
|
+
peopleCmd.Flags().StringVar(&peopleOutput, "output", "", "Write output to file")
|
|
56
|
+
peopleCmd.Flags().BoolVar(
|
|
57
|
+
&peopleResolveProfiles,
|
|
58
|
+
"resolve-free-profile",
|
|
59
|
+
true,
|
|
60
|
+
"Open Sales Navigator lead pages to resolve public /in/ URLs",
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
var peopleCmd = &cobra.Command{
|
|
65
|
+
Use: "people",
|
|
66
|
+
Short: "Discover company employees/key persons and public profile URLs",
|
|
67
|
+
Long: `Discover candidate contacts from a company page in LinkedIn/Sales Navigator.
|
|
68
|
+
|
|
69
|
+
This command focuses on atomic operations:
|
|
70
|
+
- navigate to company page
|
|
71
|
+
- click Employees / Key persons views
|
|
72
|
+
- extract candidate contacts
|
|
73
|
+
- optionally resolve public /in/ URLs from Sales Navigator lead pages
|
|
74
|
+
`,
|
|
75
|
+
Run: runPeople,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func runPeople(cmd *cobra.Command, args []string) {
|
|
79
|
+
ensureProfile()
|
|
80
|
+
|
|
81
|
+
if strings.TrimSpace(peopleCompanyURL) == "" {
|
|
82
|
+
exitWithError("--company-url is required")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
normalizedMode := strings.ToLower(strings.TrimSpace(peopleMode))
|
|
86
|
+
if normalizedMode != "employees" && normalizedMode != "key-persons" && normalizedMode != "both" {
|
|
87
|
+
exitWithError("invalid --mode: %s", peopleMode)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if peopleLimit <= 0 {
|
|
91
|
+
exitWithError("--limit must be > 0")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
normalizedFormat := strings.ToLower(strings.TrimSpace(peopleFormat))
|
|
95
|
+
if normalizedFormat != "json" && normalizedFormat != "table" && normalizedFormat != "csv" {
|
|
96
|
+
exitWithError("invalid --format: %s", peopleFormat)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if dryRun {
|
|
100
|
+
fmt.Println("[dry-run] Would:")
|
|
101
|
+
fmt.Printf(" - Navigate to company URL: %s\n", peopleCompanyURL)
|
|
102
|
+
fmt.Printf(" - Browse mode: %s\n", normalizedMode)
|
|
103
|
+
fmt.Printf(" - Extract up to %d contacts\n", peopleLimit)
|
|
104
|
+
if peopleResolveProfiles {
|
|
105
|
+
fmt.Println(" - Resolve public /in/ URLs from Sales Navigator lead pages")
|
|
106
|
+
}
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
client := pinchtab.NewClient(getPinchTabHost())
|
|
111
|
+
if err := client.Navigate(peopleCompanyURL); err != nil {
|
|
112
|
+
exitWithError("failed to navigate to company page: %v", err)
|
|
113
|
+
}
|
|
114
|
+
time.Sleep(3 * time.Second)
|
|
115
|
+
|
|
116
|
+
views := []string{}
|
|
117
|
+
if normalizedMode == "employees" || normalizedMode == "both" {
|
|
118
|
+
views = append(views, "employees")
|
|
119
|
+
}
|
|
120
|
+
if normalizedMode == "key-persons" || normalizedMode == "both" {
|
|
121
|
+
views = append(views, "key-persons")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
collected := make([]personCandidate, 0, peopleLimit)
|
|
125
|
+
seen := map[string]bool{}
|
|
126
|
+
|
|
127
|
+
for _, view := range views {
|
|
128
|
+
clicked, clickErr := clickPeopleView(client, view)
|
|
129
|
+
if clickErr != nil {
|
|
130
|
+
fmt.Fprintf(os.Stderr, "Warning: failed to click %s view: %v\n", view, clickErr)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if clicked {
|
|
134
|
+
time.Sleep(2 * time.Second)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
rows, err := extractPeopleCandidates(client, view, peopleLimit)
|
|
138
|
+
if err != nil {
|
|
139
|
+
fmt.Fprintf(os.Stderr, "Warning: failed to extract %s candidates: %v\n", view, err)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for _, row := range rows {
|
|
144
|
+
key := strings.ToLower(strings.TrimSpace(row.Name)) + "|" + row.DiscoveryURL
|
|
145
|
+
if key == "|" || seen[key] {
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
seen[key] = true
|
|
149
|
+
|
|
150
|
+
if peopleResolveProfiles && row.PublicProfile == "" && isSalesLeadURL(row.DiscoveryURL) {
|
|
151
|
+
profileURL, resolveErr := resolvePublicProfileFromLead(client, row.DiscoveryURL)
|
|
152
|
+
if resolveErr == nil && profileURL != "" {
|
|
153
|
+
row.PublicProfile = profileURL
|
|
154
|
+
row.ResolutionNote = "resolved_from_sales_lead"
|
|
155
|
+
} else if resolveErr != nil {
|
|
156
|
+
row.ResolutionNote = "resolve_failed"
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
collected = append(collected, row)
|
|
161
|
+
if len(collected) >= peopleLimit {
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if len(collected) >= peopleLimit {
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
renderPeopleOutput(collected, normalizedFormat, peopleOutput)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func clickPeopleView(client *pinchtab.Client, view string) (bool, error) {
|
|
175
|
+
terms := "['employee','employees','people']"
|
|
176
|
+
if view == "key-persons" {
|
|
177
|
+
terms = "['key person','key persons','key contact','key contacts','decision maker','decision makers','senior']"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
script := fmt.Sprintf(`(() => {
|
|
181
|
+
const terms = %s;
|
|
182
|
+
const nodes = Array.from(document.querySelectorAll('button,a,[role="button"],li,div'));
|
|
183
|
+
const match = nodes.find((el) => {
|
|
184
|
+
const text = (el.innerText || el.textContent || '').toLowerCase().trim();
|
|
185
|
+
if (!text) return false;
|
|
186
|
+
return terms.some((t) => text.includes(t));
|
|
187
|
+
});
|
|
188
|
+
if (!match) return { clicked: false };
|
|
189
|
+
match.click();
|
|
190
|
+
return { clicked: true, text: (match.innerText || match.textContent || '').trim() };
|
|
191
|
+
})()`, terms)
|
|
192
|
+
|
|
193
|
+
result, err := client.Execute(script)
|
|
194
|
+
if err != nil {
|
|
195
|
+
return false, err
|
|
196
|
+
}
|
|
197
|
+
obj, ok := result.(map[string]interface{})
|
|
198
|
+
if !ok {
|
|
199
|
+
return false, nil
|
|
200
|
+
}
|
|
201
|
+
clicked, ok := obj["clicked"].(bool)
|
|
202
|
+
if !ok {
|
|
203
|
+
return false, nil
|
|
204
|
+
}
|
|
205
|
+
return clicked, nil
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func extractPeopleCandidates(client *pinchtab.Client, sourceView string, limit int) ([]personCandidate, error) {
|
|
209
|
+
script := fmt.Sprintf(`(() => {
|
|
210
|
+
const MAX = %d;
|
|
211
|
+
const anchors = Array.from(document.querySelectorAll('a[href]'));
|
|
212
|
+
const rows = [];
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
for (const a of anchors) {
|
|
215
|
+
const href = (a.href || '').trim();
|
|
216
|
+
if (!href) continue;
|
|
217
|
+
const lower = href.toLowerCase();
|
|
218
|
+
if (!lower.includes('/in/') && !lower.includes('/sales/lead/')) continue;
|
|
219
|
+
|
|
220
|
+
let name = (a.innerText || a.textContent || '').trim();
|
|
221
|
+
if (!name) {
|
|
222
|
+
const card = a.closest('li,article,div');
|
|
223
|
+
if (card) {
|
|
224
|
+
const nameLink = card.querySelector('a[href*="/in/"], a[href*="/sales/lead/"]');
|
|
225
|
+
if (nameLink) {
|
|
226
|
+
name = (nameLink.innerText || nameLink.textContent || '').trim();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!name) continue;
|
|
231
|
+
|
|
232
|
+
const key = href.split('?')[0] + '|' + name.toLowerCase();
|
|
233
|
+
if (seen.has(key)) continue;
|
|
234
|
+
seen.add(key);
|
|
235
|
+
|
|
236
|
+
let title = '';
|
|
237
|
+
const card = a.closest('li,article,div');
|
|
238
|
+
if (card) {
|
|
239
|
+
const textBits = Array.from(card.querySelectorAll('span,div,p'))
|
|
240
|
+
.map((el) => (el.innerText || '').trim())
|
|
241
|
+
.filter(Boolean)
|
|
242
|
+
.filter((t) => t !== name)
|
|
243
|
+
.filter((t) => t.length < 160);
|
|
244
|
+
if (textBits.length > 0) {
|
|
245
|
+
title = textBits[0];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
rows.push({
|
|
250
|
+
name,
|
|
251
|
+
title,
|
|
252
|
+
discovery_url: href,
|
|
253
|
+
public_profile_url: lower.includes('/in/') ? href : ''
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (rows.length >= MAX) break;
|
|
257
|
+
}
|
|
258
|
+
return rows;
|
|
259
|
+
})()`, limit)
|
|
260
|
+
|
|
261
|
+
raw, err := client.Execute(script)
|
|
262
|
+
if err != nil {
|
|
263
|
+
return nil, err
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
items, ok := raw.([]interface{})
|
|
267
|
+
if !ok {
|
|
268
|
+
return []personCandidate{}, nil
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
results := make([]personCandidate, 0, len(items))
|
|
272
|
+
for _, item := range items {
|
|
273
|
+
row, ok := item.(map[string]interface{})
|
|
274
|
+
if !ok {
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
name := strings.TrimSpace(toString(row["name"]))
|
|
279
|
+
if name == "" {
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
discoveryURL := strings.TrimSpace(toString(row["discovery_url"]))
|
|
283
|
+
publicURL := normalizePublicProfileURL(toString(row["public_profile_url"]))
|
|
284
|
+
if publicURL == "" {
|
|
285
|
+
publicURL = normalizePublicProfileURL(discoveryURL)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
results = append(results, personCandidate{
|
|
289
|
+
Name: name,
|
|
290
|
+
Title: strings.TrimSpace(toString(row["title"])),
|
|
291
|
+
SourceView: sourceView,
|
|
292
|
+
DiscoveryURL: discoveryURL,
|
|
293
|
+
PublicProfile: publicURL,
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return results, nil
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
func resolvePublicProfileFromLead(client *pinchtab.Client, leadURL string) (string, error) {
|
|
301
|
+
if err := client.Navigate(leadURL); err != nil {
|
|
302
|
+
return "", err
|
|
303
|
+
}
|
|
304
|
+
time.Sleep(2 * time.Second)
|
|
305
|
+
|
|
306
|
+
raw, err := client.Execute(`(() => {
|
|
307
|
+
const anchors = Array.from(document.querySelectorAll('a[href]'));
|
|
308
|
+
for (const a of anchors) {
|
|
309
|
+
const href = (a.href || '').trim();
|
|
310
|
+
if (href.toLowerCase().includes('linkedin.com/in/')) {
|
|
311
|
+
return { profile_url: href };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const canonical = document.querySelector('link[rel="canonical"]')?.href || '';
|
|
315
|
+
if (canonical.toLowerCase().includes('linkedin.com/in/')) {
|
|
316
|
+
return { profile_url: canonical };
|
|
317
|
+
}
|
|
318
|
+
return { profile_url: '' };
|
|
319
|
+
})()`)
|
|
320
|
+
if err != nil {
|
|
321
|
+
return "", err
|
|
322
|
+
}
|
|
323
|
+
obj, ok := raw.(map[string]interface{})
|
|
324
|
+
if !ok {
|
|
325
|
+
return "", nil
|
|
326
|
+
}
|
|
327
|
+
return normalizePublicProfileURL(toString(obj["profile_url"])), nil
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
func isSalesLeadURL(value string) bool {
|
|
331
|
+
return strings.Contains(strings.ToLower(value), "/sales/lead/")
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
func normalizePublicProfileURL(value string) string {
|
|
335
|
+
trimmed := strings.TrimSpace(value)
|
|
336
|
+
if trimmed == "" {
|
|
337
|
+
return ""
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if fromSales := convertSalesLeadURLToPublicProfile(trimmed); fromSales != "" {
|
|
341
|
+
return fromSales
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if !strings.Contains(strings.ToLower(trimmed), "linkedin.com/in/") {
|
|
345
|
+
return ""
|
|
346
|
+
}
|
|
347
|
+
base := strings.Split(trimmed, "?")[0]
|
|
348
|
+
base = strings.Split(base, "#")[0]
|
|
349
|
+
base = strings.TrimRight(base, "/")
|
|
350
|
+
return base
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
func convertSalesLeadURLToPublicProfile(raw string) string {
|
|
354
|
+
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
355
|
+
if err != nil {
|
|
356
|
+
return ""
|
|
357
|
+
}
|
|
358
|
+
host := strings.ToLower(parsed.Host)
|
|
359
|
+
if host != "" && !strings.Contains(host, "linkedin.com") {
|
|
360
|
+
return ""
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
path := strings.TrimSpace(parsed.Path)
|
|
364
|
+
lowerPath := strings.ToLower(path)
|
|
365
|
+
if !strings.Contains(lowerPath, "/sales/lead/") {
|
|
366
|
+
return ""
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
parts := strings.Split(path, "/sales/lead/")
|
|
370
|
+
if len(parts) < 2 {
|
|
371
|
+
return ""
|
|
372
|
+
}
|
|
373
|
+
tail := parts[len(parts)-1]
|
|
374
|
+
if tail == "" {
|
|
375
|
+
return ""
|
|
376
|
+
}
|
|
377
|
+
memberPart := strings.Split(tail, ",")[0]
|
|
378
|
+
memberPart = strings.Trim(memberPart, "/")
|
|
379
|
+
if memberPart == "" {
|
|
380
|
+
return ""
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return "https://www.linkedin.com/in/" + memberPart
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
func toString(value interface{}) string {
|
|
387
|
+
if value == nil {
|
|
388
|
+
return ""
|
|
389
|
+
}
|
|
390
|
+
if text, ok := value.(string); ok {
|
|
391
|
+
return text
|
|
392
|
+
}
|
|
393
|
+
return fmt.Sprintf("%v", value)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
func renderPeopleOutput(rows []personCandidate, format string, outputFile string) {
|
|
397
|
+
if format == "json" {
|
|
398
|
+
payload, _ := json.MarshalIndent(rows, "", " ")
|
|
399
|
+
writeOrPrint(string(payload), outputFile)
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if format == "csv" {
|
|
404
|
+
builder := &strings.Builder{}
|
|
405
|
+
writer := csv.NewWriter(builder)
|
|
406
|
+
_ = writer.Write([]string{"name", "title", "source_view", "discovery_url", "public_profile_url", "resolution_note"})
|
|
407
|
+
for _, row := range rows {
|
|
408
|
+
_ = writer.Write([]string{row.Name, row.Title, row.SourceView, row.DiscoveryURL, row.PublicProfile, row.ResolutionNote})
|
|
409
|
+
}
|
|
410
|
+
writer.Flush()
|
|
411
|
+
writeOrPrint(builder.String(), outputFile)
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
builder := &strings.Builder{}
|
|
416
|
+
if len(rows) == 0 {
|
|
417
|
+
builder.WriteString("No people found\n")
|
|
418
|
+
} else {
|
|
419
|
+
for _, row := range rows {
|
|
420
|
+
builder.WriteString(
|
|
421
|
+
fmt.Sprintf(
|
|
422
|
+
"%s | %s | %s | %s\n",
|
|
423
|
+
row.Name,
|
|
424
|
+
emptyDash(row.Title),
|
|
425
|
+
emptyDash(row.SourceView),
|
|
426
|
+
emptyDash(row.PublicProfile),
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
writeOrPrint(builder.String(), outputFile)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
func emptyDash(value string) string {
|
|
435
|
+
if strings.TrimSpace(value) == "" {
|
|
436
|
+
return "-"
|
|
437
|
+
}
|
|
438
|
+
return value
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
func writeOrPrint(content string, outputFile string) {
|
|
442
|
+
if strings.TrimSpace(outputFile) == "" {
|
|
443
|
+
fmt.Print(content)
|
|
444
|
+
if !strings.HasSuffix(content, "\n") {
|
|
445
|
+
fmt.Println()
|
|
446
|
+
}
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if err := os.WriteFile(outputFile, []byte(content), 0644); err != nil {
|
|
451
|
+
exitWithError("failed to write output: %v", err)
|
|
452
|
+
}
|
|
453
|
+
fmt.Printf("Wrote output to %s\n", outputFile)
|
|
454
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"text/tabwriter"
|
|
7
|
+
|
|
8
|
+
"github.com/spf13/cobra"
|
|
9
|
+
"github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func init() {
|
|
13
|
+
rootCmd.AddCommand(profilesCmd)
|
|
14
|
+
profilesCmd.AddCommand(profilesListCmd)
|
|
15
|
+
profilesCmd.AddCommand(profilesRemoveCmd)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
var profilesCmd = &cobra.Command{
|
|
19
|
+
Use: "profiles",
|
|
20
|
+
Short: "Manage LinkedIn profiles",
|
|
21
|
+
Long: `List, or remove LinkedIn profiles.`,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var profilesListCmd = &cobra.Command{
|
|
25
|
+
Use: "list",
|
|
26
|
+
Short: "List all profiles",
|
|
27
|
+
Run: runProfilesList,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func runProfilesList(cmd *cobra.Command, args []string) {
|
|
31
|
+
cfg, err := getConfigManager()
|
|
32
|
+
if err != nil {
|
|
33
|
+
exitWithError("Failed to initialize config: %v", err)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
profiles, err := cfg.ListProfiles()
|
|
37
|
+
if err != nil {
|
|
38
|
+
exitWithError("Failed to list profiles: %v", err)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if len(profiles) == 0 {
|
|
42
|
+
fmt.Println("No profiles found.")
|
|
43
|
+
fmt.Println("Use 'linkedin auth --profile <name>' to create one.")
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Load rate limits for status
|
|
48
|
+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
49
|
+
fmt.Fprintln(w, "NAME\tLAST USED")
|
|
50
|
+
|
|
51
|
+
for _, name := range profiles {
|
|
52
|
+
profile, err := cfg.LoadProfile(name)
|
|
53
|
+
if err != nil {
|
|
54
|
+
fmt.Fprintf(w, "%s\t<error>\n", name)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
lastUsed := "Never"
|
|
59
|
+
if !profile.LastUsed.IsZero() {
|
|
60
|
+
lastUsed = profile.LastUsed.Format("2006-01-02 15:04")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fmt.Fprintf(w, "%s\t%s\n", profile.Name, lastUsed)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
w.Flush()
|
|
67
|
+
|
|
68
|
+
// Show rate limits
|
|
69
|
+
fmt.Println("\nRate Limits:")
|
|
70
|
+
for _, name := range profiles {
|
|
71
|
+
limiter := ratelimit.NewLimiter(name, ratelimit.DefaultLimits(), cfg)
|
|
72
|
+
status, err := limiter.Status()
|
|
73
|
+
if err != nil {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fmt.Printf(" %s: %d/%d connections today, %d/%d messages today\n",
|
|
78
|
+
name,
|
|
79
|
+
status["connections_today"],
|
|
80
|
+
status["connections_limit"],
|
|
81
|
+
status["messages_today"],
|
|
82
|
+
status["messages_limit"],
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
var profilesRemoveCmd = &cobra.Command{
|
|
88
|
+
Use: "remove [name]",
|
|
89
|
+
Short: "Remove a profile",
|
|
90
|
+
Args: cobra.ExactArgs(1),
|
|
91
|
+
Run: runProfilesRemove,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func runProfilesRemove(cmd *cobra.Command, args []string) {
|
|
95
|
+
name := args[0]
|
|
96
|
+
|
|
97
|
+
cfg, err := getConfigManager()
|
|
98
|
+
if err != nil {
|
|
99
|
+
exitWithError("Failed to initialize config: %v", err)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if !cfg.ProfileExists(name) {
|
|
103
|
+
exitWithError("Profile '%s' not found", name)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fmt.Printf("Are you sure you want to remove profile '%s'? (y/N): ", name)
|
|
107
|
+
var response string
|
|
108
|
+
fmt.Scanln(&response)
|
|
109
|
+
|
|
110
|
+
if response != "y" && response != "Y" {
|
|
111
|
+
fmt.Println("Cancelled.")
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if err := cfg.DeleteProfile(name); err != nil {
|
|
116
|
+
exitWithError("Failed to remove profile: %v", err)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fmt.Printf("✓ Profile '%s' removed.\n", name)
|
|
120
|
+
fmt.Println("Note: The PinchTab browser profile is still saved. Remove it manually if needed.")
|
|
121
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
|
|
7
|
+
"github.com/spf13/cobra"
|
|
8
|
+
"github.com/thaddeus-git/linkedin-cli/internal/config"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
var (
|
|
12
|
+
profileName string
|
|
13
|
+
dryRun bool
|
|
14
|
+
verbose bool
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// rootCmd represents the base command
|
|
18
|
+
var rootCmd = &cobra.Command{
|
|
19
|
+
Use: "linkedin",
|
|
20
|
+
Short: "LinkedIn automation CLI",
|
|
21
|
+
Long: `A CLI tool for LinkedIn automation using PinchTab.
|
|
22
|
+
|
|
23
|
+
Prerequisites:
|
|
24
|
+
1. Install PinchTab: curl -fsSL https://pinchtab.com/install.sh | bash
|
|
25
|
+
2. Start PinchTab: pinchtab
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
# Authenticate a profile
|
|
29
|
+
linkedin auth --profile john
|
|
30
|
+
|
|
31
|
+
# Send a connection request
|
|
32
|
+
linkedin connect --profile john --url linkedin.com/in/alice
|
|
33
|
+
|
|
34
|
+
# Send a message
|
|
35
|
+
linkedin message --profile john --url linkedin.com/in/alice --message "Hello!"
|
|
36
|
+
|
|
37
|
+
# Run sequence step
|
|
38
|
+
linkedin sequence --profile john --url linkedin.com/in/alice --step connect --message "Hi Alice"
|
|
39
|
+
|
|
40
|
+
# Discover contacts from company page
|
|
41
|
+
linkedin people --profile john --company-url "https://www.linkedin.com/sales/company/123456" --mode both --limit 10
|
|
42
|
+
`,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Execute adds all child commands to the root command
|
|
46
|
+
func Execute() {
|
|
47
|
+
if err := rootCmd.Execute(); err != nil {
|
|
48
|
+
fmt.Fprintln(os.Stderr, err)
|
|
49
|
+
os.Exit(1)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func init() {
|
|
54
|
+
rootCmd.PersistentFlags().StringVarP(&profileName, "profile", "p", "", "Profile name (env: LINKEDIN_PROFILE)")
|
|
55
|
+
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Show what would be done without executing")
|
|
56
|
+
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output")
|
|
57
|
+
|
|
58
|
+
// Set profile from environment if not provided
|
|
59
|
+
if profileName == "" {
|
|
60
|
+
profileName = os.Getenv("LINKEDIN_PROFILE")
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// getConfigManager returns a config manager
|
|
65
|
+
func getConfigManager() (*config.Manager, error) {
|
|
66
|
+
return config.NewManager()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// getPinchTabHost returns PinchTab host from environment
|
|
70
|
+
func getPinchTabHost() string {
|
|
71
|
+
host := os.Getenv("PINCHTAB_HOST")
|
|
72
|
+
if host == "" {
|
|
73
|
+
return "http://localhost:9867"
|
|
74
|
+
}
|
|
75
|
+
return host
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// exitWithError prints error and exits
|
|
79
|
+
func exitWithError(format string, args ...interface{}) {
|
|
80
|
+
fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...)
|
|
81
|
+
os.Exit(1)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// logVerbose prints if verbose mode is enabled
|
|
85
|
+
func logVerbose(format string, args ...interface{}) {
|
|
86
|
+
if verbose {
|
|
87
|
+
fmt.Printf("[verbose] "+format+"\n", args...)
|
|
88
|
+
}
|
|
89
|
+
}
|