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,192 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/csv"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"strings"
|
|
8
|
+
|
|
9
|
+
"github.com/spf13/cobra"
|
|
10
|
+
"github.com/thaddeus-git/linkedin-cli/internal/linkedin"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
var (
|
|
14
|
+
sequenceURL string
|
|
15
|
+
sequenceStep string
|
|
16
|
+
sequenceMessage string
|
|
17
|
+
sequencePDFURL string
|
|
18
|
+
sequenceFile string
|
|
19
|
+
sequenceContinue bool
|
|
20
|
+
sequenceDefaultStep string
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
func init() {
|
|
24
|
+
rootCmd.AddCommand(sequenceCmd)
|
|
25
|
+
sequenceCmd.Flags().StringVarP(&sequenceURL, "url", "u", "", "LinkedIn profile URL")
|
|
26
|
+
sequenceCmd.Flags().StringVarP(&sequenceStep, "step", "s", "", "Step: connect|followup|pdf-followup")
|
|
27
|
+
sequenceCmd.Flags().StringVarP(&sequenceMessage, "message", "m", "", "Message content")
|
|
28
|
+
sequenceCmd.Flags().StringVar(&sequencePDFURL, "pdf-url", "", "Public PDF URL to include in message")
|
|
29
|
+
sequenceCmd.Flags().StringVarP(&sequenceFile, "file", "f", "", "CSV file with sequence actions (url,step,message,pdf_url)")
|
|
30
|
+
sequenceCmd.Flags().BoolVar(&sequenceContinue, "continue-on-error", false, "Continue processing rows after an error")
|
|
31
|
+
sequenceCmd.Flags().StringVar(&sequenceDefaultStep, "default-step", "", "Default step for CSV rows missing step")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
var sequenceCmd = &cobra.Command{
|
|
35
|
+
Use: "sequence",
|
|
36
|
+
Short: "Run repeatable LinkedIn outreach steps",
|
|
37
|
+
Long: `Run outreach sequence actions for one profile URL or from CSV.
|
|
38
|
+
|
|
39
|
+
Single target examples:
|
|
40
|
+
linkedin sequence --profile john --url linkedin.com/in/alice --step connect --message "Hi Alice"
|
|
41
|
+
linkedin sequence --profile john --url linkedin.com/in/alice --step followup --message "Thanks for accepting"
|
|
42
|
+
linkedin sequence --profile john --url linkedin.com/in/alice --step pdf-followup --message "Sharing brochure" --pdf-url "https://example.com/brochure.pdf"
|
|
43
|
+
|
|
44
|
+
Batch example:
|
|
45
|
+
linkedin sequence --profile john --file sequence.csv --continue-on-error
|
|
46
|
+
|
|
47
|
+
CSV columns: url,step,message,pdf_url
|
|
48
|
+
`,
|
|
49
|
+
Run: runSequence,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func runSequence(cmd *cobra.Command, args []string) {
|
|
53
|
+
ensureProfile()
|
|
54
|
+
|
|
55
|
+
if sequenceFile != "" {
|
|
56
|
+
runSequenceFile()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if sequenceURL == "" || sequenceStep == "" {
|
|
61
|
+
exitWithError("--url and --step are required for single-target sequence")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
runSingleSequence(sequenceURL, sequenceStep, sequenceMessage, sequencePDFURL)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func runSequenceFile() {
|
|
68
|
+
file, err := os.Open(sequenceFile)
|
|
69
|
+
if err != nil {
|
|
70
|
+
exitWithError("Failed to open file: %v", err)
|
|
71
|
+
}
|
|
72
|
+
defer file.Close()
|
|
73
|
+
|
|
74
|
+
reader := csv.NewReader(file)
|
|
75
|
+
rows, err := reader.ReadAll()
|
|
76
|
+
if err != nil {
|
|
77
|
+
exitWithError("Failed to read CSV: %v", err)
|
|
78
|
+
}
|
|
79
|
+
if len(rows) == 0 {
|
|
80
|
+
exitWithError("CSV is empty")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
headers := make(map[string]int)
|
|
84
|
+
for i, h := range rows[0] {
|
|
85
|
+
headers[strings.TrimSpace(strings.ToLower(h))] = i
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
urlIdx, ok := headers["url"]
|
|
89
|
+
if !ok {
|
|
90
|
+
exitWithError("CSV must include url column")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
stepIdx, hasStep := headers["step"]
|
|
94
|
+
msgIdx, hasMsg := headers["message"]
|
|
95
|
+
pdfIdx, hasPDF := headers["pdf_url"]
|
|
96
|
+
|
|
97
|
+
failed := 0
|
|
98
|
+
processed := 0
|
|
99
|
+
for i := 1; i < len(rows); i++ {
|
|
100
|
+
row := rows[i]
|
|
101
|
+
if len(row) <= urlIdx {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
url := strings.TrimSpace(row[urlIdx])
|
|
106
|
+
if url == "" {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
step := sequenceDefaultStep
|
|
111
|
+
if hasStep && len(row) > stepIdx {
|
|
112
|
+
cell := strings.TrimSpace(row[stepIdx])
|
|
113
|
+
if cell != "" {
|
|
114
|
+
step = cell
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
message := ""
|
|
119
|
+
if hasMsg && len(row) > msgIdx {
|
|
120
|
+
message = strings.TrimSpace(row[msgIdx])
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pdfURL := ""
|
|
124
|
+
if hasPDF && len(row) > pdfIdx {
|
|
125
|
+
pdfURL = strings.TrimSpace(row[pdfIdx])
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if step == "" {
|
|
129
|
+
failed++
|
|
130
|
+
fmt.Printf("[row %d] missing step\n", i+1)
|
|
131
|
+
if !sequenceContinue {
|
|
132
|
+
exitWithError("aborting on first error")
|
|
133
|
+
}
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fmt.Printf("[row %d] %s -> %s\n", i+1, url, step)
|
|
138
|
+
err = runSingleSequenceErr(url, step, message, pdfURL)
|
|
139
|
+
if err != nil {
|
|
140
|
+
failed++
|
|
141
|
+
fmt.Printf("[row %d] error: %v\n", i+1, err)
|
|
142
|
+
if !sequenceContinue {
|
|
143
|
+
exitWithError("aborting on first error")
|
|
144
|
+
}
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
processed++
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fmt.Printf("Sequence complete: processed=%d failed=%d\n", processed, failed)
|
|
151
|
+
if failed > 0 {
|
|
152
|
+
os.Exit(1)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func runSingleSequence(url string, step string, message string, pdfURL string) {
|
|
157
|
+
if err := runSingleSequenceErr(url, step, message, pdfURL); err != nil {
|
|
158
|
+
exitWithError("sequence failed: %v", err)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func runSingleSequenceErr(url string, step string, message string, pdfURL string) error {
|
|
163
|
+
profileURL, err := linkedin.ValidateProfileURL(url)
|
|
164
|
+
if err != nil {
|
|
165
|
+
return err
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
normalizedStep := strings.TrimSpace(strings.ToLower(step))
|
|
169
|
+
switch normalizedStep {
|
|
170
|
+
case "connect":
|
|
171
|
+
sendConnectionAction(profileURL, message)
|
|
172
|
+
return nil
|
|
173
|
+
case "followup":
|
|
174
|
+
if strings.TrimSpace(message) == "" {
|
|
175
|
+
return fmt.Errorf("followup step requires --message")
|
|
176
|
+
}
|
|
177
|
+
sendMessageAction(profileURL, message)
|
|
178
|
+
return nil
|
|
179
|
+
case "pdf-followup":
|
|
180
|
+
if strings.TrimSpace(message) == "" {
|
|
181
|
+
return fmt.Errorf("pdf-followup step requires --message")
|
|
182
|
+
}
|
|
183
|
+
full := message
|
|
184
|
+
if strings.TrimSpace(pdfURL) != "" {
|
|
185
|
+
full = fmt.Sprintf("%s\n\nPDF: %s", message, pdfURL)
|
|
186
|
+
}
|
|
187
|
+
sendMessageAction(profileURL, full)
|
|
188
|
+
return nil
|
|
189
|
+
default:
|
|
190
|
+
return fmt.Errorf("invalid step: %s", step)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
package config
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Manager handles configuration and state
|
|
12
|
+
type Manager struct {
|
|
13
|
+
configDir string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// NewManager creates a new config manager
|
|
17
|
+
func NewManager() (*Manager, error) {
|
|
18
|
+
home, err := os.UserHomeDir()
|
|
19
|
+
if err != nil {
|
|
20
|
+
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
configDir := filepath.Join(home, ".linkedin-cli")
|
|
24
|
+
if err := os.MkdirAll(configDir, 0755); err != nil {
|
|
25
|
+
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return &Manager{configDir: configDir}, nil
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// GetConfigDir returns the config directory path
|
|
32
|
+
func (m *Manager) GetConfigDir() string {
|
|
33
|
+
return m.configDir
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ProfileExists checks if a profile exists
|
|
37
|
+
func (m *Manager) ProfileExists(name string) bool {
|
|
38
|
+
path := filepath.Join(m.configDir, "profiles", name+".json")
|
|
39
|
+
_, err := os.Stat(path)
|
|
40
|
+
return err == nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// LoadProfile loads a profile
|
|
44
|
+
func (m *Manager) LoadProfile(name string) (*Profile, error) {
|
|
45
|
+
path := filepath.Join(m.configDir, "profiles", name+".json")
|
|
46
|
+
|
|
47
|
+
data, err := os.ReadFile(path)
|
|
48
|
+
if err != nil {
|
|
49
|
+
if os.IsNotExist(err) {
|
|
50
|
+
return nil, fmt.Errorf("profile '%s' not found", name)
|
|
51
|
+
}
|
|
52
|
+
return nil, fmt.Errorf("failed to read profile: %w", err)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
var profile Profile
|
|
56
|
+
if err := json.Unmarshal(data, &profile); err != nil {
|
|
57
|
+
return nil, fmt.Errorf("failed to parse profile: %w", err)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return &profile, nil
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// SaveProfile saves a profile
|
|
64
|
+
func (m *Manager) SaveProfile(profile *Profile) error {
|
|
65
|
+
profilesDir := filepath.Join(m.configDir, "profiles")
|
|
66
|
+
if err := os.MkdirAll(profilesDir, 0755); err != nil {
|
|
67
|
+
return fmt.Errorf("failed to create profiles directory: %w", err)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
path := filepath.Join(profilesDir, profile.Name+".json")
|
|
71
|
+
|
|
72
|
+
data, err := json.MarshalIndent(profile, "", " ")
|
|
73
|
+
if err != nil {
|
|
74
|
+
return fmt.Errorf("failed to marshal profile: %w", err)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if err := os.WriteFile(path, data, 0644); err != nil {
|
|
78
|
+
return fmt.Errorf("failed to write profile: %w", err)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return nil
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ListProfiles lists all profiles
|
|
85
|
+
func (m *Manager) ListProfiles() ([]string, error) {
|
|
86
|
+
profilesDir := filepath.Join(m.configDir, "profiles")
|
|
87
|
+
|
|
88
|
+
entries, err := os.ReadDir(profilesDir)
|
|
89
|
+
if err != nil {
|
|
90
|
+
if os.IsNotExist(err) {
|
|
91
|
+
return []string{}, nil
|
|
92
|
+
}
|
|
93
|
+
return nil, fmt.Errorf("failed to read profiles directory: %w", err)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
var profiles []string
|
|
97
|
+
for _, entry := range entries {
|
|
98
|
+
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
|
|
99
|
+
name := entry.Name()[:len(entry.Name())-5] // Remove .json
|
|
100
|
+
profiles = append(profiles, name)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return profiles, nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// DeleteProfile deletes a profile
|
|
108
|
+
func (m *Manager) DeleteProfile(name string) error {
|
|
109
|
+
path := filepath.Join(m.configDir, "profiles", name+".json")
|
|
110
|
+
if err := os.Remove(path); err != nil {
|
|
111
|
+
if os.IsNotExist(err) {
|
|
112
|
+
return fmt.Errorf("profile '%s' not found", name)
|
|
113
|
+
}
|
|
114
|
+
return fmt.Errorf("failed to delete profile: %w", err)
|
|
115
|
+
}
|
|
116
|
+
return nil
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// LoadRateLimits loads rate limits for a profile
|
|
120
|
+
func (m *Manager) LoadRateLimits(profileName string) (*RateLimits, error) {
|
|
121
|
+
path := filepath.Join(m.configDir, "ratelimit.json")
|
|
122
|
+
|
|
123
|
+
data, err := os.ReadFile(path)
|
|
124
|
+
if err != nil {
|
|
125
|
+
if os.IsNotExist(err) {
|
|
126
|
+
return &RateLimits{}, nil
|
|
127
|
+
}
|
|
128
|
+
return nil, fmt.Errorf("failed to read rate limits: %w", err)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
var allLimits map[string]*RateLimits
|
|
132
|
+
if err := json.Unmarshal(data, &allLimits); err != nil {
|
|
133
|
+
return nil, fmt.Errorf("failed to parse rate limits: %w", err)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
limits, exists := allLimits[profileName]
|
|
137
|
+
if !exists {
|
|
138
|
+
return &RateLimits{}, nil
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Reset counters if needed
|
|
142
|
+
limits.ResetIfNeeded()
|
|
143
|
+
|
|
144
|
+
return limits, nil
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// SaveRateLimits saves rate limits for a profile
|
|
148
|
+
func (m *Manager) SaveRateLimits(profileName string, limits *RateLimits) error {
|
|
149
|
+
path := filepath.Join(m.configDir, "ratelimit.json")
|
|
150
|
+
|
|
151
|
+
var allLimits map[string]*RateLimits
|
|
152
|
+
|
|
153
|
+
// Load existing
|
|
154
|
+
data, err := os.ReadFile(path)
|
|
155
|
+
if err == nil {
|
|
156
|
+
json.Unmarshal(data, &allLimits)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if allLimits == nil {
|
|
160
|
+
allLimits = make(map[string]*RateLimits)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
allLimits[profileName] = limits
|
|
164
|
+
|
|
165
|
+
// Save all
|
|
166
|
+
data, err = json.MarshalIndent(allLimits, "", " ")
|
|
167
|
+
if err != nil {
|
|
168
|
+
return fmt.Errorf("failed to marshal rate limits: %w", err)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if err := os.WriteFile(path, data, 0644); err != nil {
|
|
172
|
+
return fmt.Errorf("failed to write rate limits: %w", err)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return nil
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// UpdateLastUsed updates the last used timestamp for a profile
|
|
179
|
+
func (m *Manager) UpdateLastUsed(profileName string) error {
|
|
180
|
+
profile, err := m.LoadProfile(profileName)
|
|
181
|
+
if err != nil {
|
|
182
|
+
return err
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
profile.LastUsed = time.Now()
|
|
186
|
+
return m.SaveProfile(profile)
|
|
187
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
package config
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"testing"
|
|
6
|
+
"time"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestNewManager(t *testing.T) {
|
|
10
|
+
mgr, err := NewManager()
|
|
11
|
+
if err != nil {
|
|
12
|
+
t.Fatalf("unexpected error: %v", err)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if mgr.configDir == "" {
|
|
16
|
+
t.Error("config dir should not be empty")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Cleanup
|
|
20
|
+
os.RemoveAll(mgr.configDir)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func TestProfileCRUD(t *testing.T) {
|
|
24
|
+
// Use temp dir for testing
|
|
25
|
+
tempDir := t.TempDir()
|
|
26
|
+
mgr := &Manager{configDir: tempDir}
|
|
27
|
+
|
|
28
|
+
// Create profile
|
|
29
|
+
profile := &Profile{
|
|
30
|
+
Name: "test",
|
|
31
|
+
CreatedAt: time.Now(),
|
|
32
|
+
LastUsed: time.Now(),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if err := mgr.SaveProfile(profile); err != nil {
|
|
36
|
+
t.Fatalf("failed to save profile: %v", err)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check exists
|
|
40
|
+
if !mgr.ProfileExists("test") {
|
|
41
|
+
t.Error("profile should exist")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Load
|
|
45
|
+
loaded, err := mgr.LoadProfile("test")
|
|
46
|
+
if err != nil {
|
|
47
|
+
t.Fatalf("failed to load profile: %v", err)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if loaded.Name != "test" {
|
|
51
|
+
t.Errorf("expected name 'test', got %s", loaded.Name)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// List
|
|
55
|
+
profiles, err := mgr.ListProfiles()
|
|
56
|
+
if err != nil {
|
|
57
|
+
t.Fatalf("failed to list profiles: %v", err)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if len(profiles) != 1 || profiles[0] != "test" {
|
|
61
|
+
t.Errorf("expected ['test'], got %v", profiles)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Delete
|
|
65
|
+
if err := mgr.DeleteProfile("test"); err != nil {
|
|
66
|
+
t.Fatalf("failed to delete profile: %v", err)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if mgr.ProfileExists("test") {
|
|
70
|
+
t.Error("profile should not exist after delete")
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func TestRateLimits(t *testing.T) {
|
|
75
|
+
tempDir := t.TempDir()
|
|
76
|
+
mgr := &Manager{configDir: tempDir}
|
|
77
|
+
|
|
78
|
+
// Use today's date to ensure the counters don't get reset
|
|
79
|
+
now := time.Now()
|
|
80
|
+
limits := &RateLimits{
|
|
81
|
+
Connections: RateLimit{
|
|
82
|
+
Today: 15,
|
|
83
|
+
ThisWeek: 87,
|
|
84
|
+
LastAction: now,
|
|
85
|
+
},
|
|
86
|
+
Messages: RateLimit{
|
|
87
|
+
Today: 23,
|
|
88
|
+
LastAction: now,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Save
|
|
93
|
+
if err := mgr.SaveRateLimits("john", limits); err != nil {
|
|
94
|
+
t.Fatalf("failed to save rate limits: %v", err)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Load
|
|
98
|
+
loaded, err := mgr.LoadRateLimits("john")
|
|
99
|
+
if err != nil {
|
|
100
|
+
t.Fatalf("failed to load rate limits: %v", err)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if loaded.Connections.Today != 15 {
|
|
104
|
+
t.Errorf("expected 15 connections today, got %d", loaded.Connections.Today)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func TestRateLimitCanConnect(t *testing.T) {
|
|
109
|
+
limits := &RateLimits{
|
|
110
|
+
Connections: RateLimit{Today: 19, ThisWeek: 99},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if !limits.CanConnect(20, 100) {
|
|
114
|
+
t.Error("should be able to connect at 19/20 daily")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
limits.Connections.Today = 20
|
|
118
|
+
if limits.CanConnect(20, 100) {
|
|
119
|
+
t.Error("should not be able to connect at 20/20 daily")
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
package config
|
|
2
|
+
|
|
3
|
+
import "time"
|
|
4
|
+
|
|
5
|
+
// Profile represents a LinkedIn profile configuration
|
|
6
|
+
type Profile struct {
|
|
7
|
+
Name string `json:"name"`
|
|
8
|
+
CreatedAt time.Time `json:"created_at"`
|
|
9
|
+
LastUsed time.Time `json:"last_used"`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// RateLimits tracks rate limit state per profile
|
|
13
|
+
type RateLimits struct {
|
|
14
|
+
Connections RateLimit `json:"connections"`
|
|
15
|
+
Messages RateLimit `json:"messages"`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// RateLimit tracks a single rate limit
|
|
19
|
+
type RateLimit struct {
|
|
20
|
+
Today int `json:"today"`
|
|
21
|
+
ThisWeek int `json:"this_week"`
|
|
22
|
+
LastAction time.Time `json:"last_action"`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// CanConnect checks if connection request is allowed
|
|
26
|
+
func (r *RateLimits) CanConnect(dailyLimit, weeklyLimit int) bool {
|
|
27
|
+
return r.Connections.Today < dailyLimit && r.Connections.ThisWeek < weeklyLimit
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// CanMessage checks if message is allowed
|
|
31
|
+
func (r *RateLimits) CanMessage(dailyLimit int) bool {
|
|
32
|
+
return r.Messages.Today < dailyLimit
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// RecordConnection records a connection request
|
|
36
|
+
func (r *RateLimits) RecordConnection() {
|
|
37
|
+
r.Connections.Today++
|
|
38
|
+
r.Connections.ThisWeek++
|
|
39
|
+
r.Connections.LastAction = time.Now()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// RecordMessage records a message sent
|
|
43
|
+
func (r *RateLimits) RecordMessage() {
|
|
44
|
+
r.Messages.Today++
|
|
45
|
+
r.Messages.LastAction = time.Now()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ResetIfNeeded resets daily/weekly counters if day/week has changed
|
|
49
|
+
func (r *RateLimits) ResetIfNeeded() {
|
|
50
|
+
now := time.Now()
|
|
51
|
+
last := r.Connections.LastAction
|
|
52
|
+
|
|
53
|
+
// Reset daily if different day
|
|
54
|
+
if last.Year() != now.Year() || last.YearDay() != now.YearDay() {
|
|
55
|
+
r.Connections.Today = 0
|
|
56
|
+
r.Messages.Today = 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Reset weekly if different week
|
|
60
|
+
_, lastWeek := last.ISOWeek()
|
|
61
|
+
_, thisWeek := now.ISOWeek()
|
|
62
|
+
if lastWeek != thisWeek {
|
|
63
|
+
r.Connections.ThisWeek = 0
|
|
64
|
+
}
|
|
65
|
+
}
|