package linkedin import ( "context" "encoding/json" "errors" "fmt" "io" "math/rand" "net/http" "os" "os/exec" "path/filepath" "strings" "sync" "syscall" "time" "github.com/chromedp/cdproto/target" "github.com/chromedp/chromedp" ) var ( ErrAuth = errors.New("linkedin: auth required and timed out waiting for login") ErrRateLimited = errors.New("linkedin: reply rate limit reached (5/hr)") ErrHeadlessAuth = errors.New("linkedin: login required - run `apex linkedin login` in a terminal with a display first") ) const ( randomDelayMin = 800 randomDelayRange = 1000 replyRateLimit = 25 inviteRateLimit = 20 defaultDebugPort = "9222" // apexDebugPort is the port apex uses for its own persistent browser instance. // Distinct from 9222 to avoid colliding with the user's main browser. apexDebugPort = "9223" ) var rateMu sync.Mutex type Session struct { allocCancel context.CancelFunc cancel context.CancelFunc ctx context.Context attached bool // true when connected to existing browser; Close won't kill it } func NewSession(ctx context.Context) (*Session, error) { // Try to attach to a running apex browser first (or a user-specified port). port := os.Getenv("LINKEDIN_DEBUG_PORT") if port == "" { port = apexDebugPort } if allocCtx, allocCancel, ok := tryAttachDebugger(ctx, port); ok { // Attach to the existing page tab — never open or close tabs. var taskCtx context.Context var taskCancel context.CancelFunc if tabID := listPageTabID(port); tabID != "" { taskCtx, taskCancel = chromedp.NewContext(allocCtx, chromedp.WithTargetID(target.ID(tabID))) } else { taskCtx, taskCancel = chromedp.NewContext(allocCtx) } if err := chromedp.Run(taskCtx); err != nil { taskCancel() allocCancel() // fall through to own browser } else { sCtx, cancel := context.WithCancel(taskCtx) _ = taskCancel return &Session{ allocCancel: allocCancel, cancel: cancel, ctx: sCtx, attached: true, }, nil } } // No running browser with debug port — launch our own as a detached process. home, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("linkedin: cannot determine home dir: %w", err) } profileDir := filepath.Join(home, ".apex", "browser-data") if env := os.Getenv("LINKEDIN_CHROME_PROFILE"); env != "" { abs, err := filepath.Abs(env) if err != nil { return nil, fmt.Errorf("linkedin: invalid LINKEDIN_CHROME_PROFILE: %w", err) } if !strings.HasPrefix(abs, home+string(filepath.Separator)) && abs != home { return nil, fmt.Errorf("linkedin: LINKEDIN_CHROME_PROFILE must be within home directory") } profileDir = abs } if err := os.MkdirAll(profileDir, 0700); err != nil { return nil, fmt.Errorf("linkedin: create browser data dir: %w", err) } browserExec := findBrowserExec() if browserExec == "" { return nil, fmt.Errorf("linkedin: no supported browser found (install Brave or Chrome)") } args := []string{ "--remote-debugging-port=" + apexDebugPort, "--user-data-dir=" + profileDir, "--disable-blink-features=AutomationControlled", "--no-first-run", "--no-default-browser-check", "--disable-gpu", "--disable-dev-shm-usage", "--password-store=basic", `--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36`, "about:blank", } if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { args = append(args, "--headless=new", "--no-sandbox") } // ponytail: launch browser as a fully detached process so it outlives this Go process. // cmd.Start() without cmd.Wait() leaves the child reparented to init on Go exit. cmd := exec.Command(browserExec, args...) if err := cmd.Start(); err != nil { return nil, fmt.Errorf("linkedin: launch browser: %w", err) } // Poll until the debug port responds (up to 10s). var allocCtx context.Context var allocCancel context.CancelFunc deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { time.Sleep(500 * time.Millisecond) if ac, cancel, ok := tryAttachDebugger(context.Background(), apexDebugPort); ok { allocCtx, allocCancel = ac, cancel break } } if allocCtx == nil { return nil, fmt.Errorf("linkedin: browser did not open debug port %s within 10s", apexDebugPort) } var taskCtx context.Context var taskCancel context.CancelFunc if tabID := listPageTabID(apexDebugPort); tabID != "" { taskCtx, taskCancel = chromedp.NewContext(allocCtx, chromedp.WithTargetID(target.ID(tabID))) } else { taskCtx, taskCancel = chromedp.NewContext(allocCtx) } if err := chromedp.Run(taskCtx); err != nil { taskCancel() allocCancel() return nil, err } sCtx, cancel := context.WithCancel(taskCtx) _ = taskCancel return &Session{ allocCancel: allocCancel, cancel: cancel, ctx: sCtx, attached: true, }, nil } // listPageTabID returns the CDP target ID of the first existing page tab, or "" if none. func listPageTabID(port string) string { client := &http.Client{Timeout: 500 * time.Millisecond} resp, err := client.Get(fmt.Sprintf("http://localhost:%s/json/list", port)) if err != nil { return "" } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "" } var tabs []struct { ID string `json:"id"` Type string `json:"type"` } if err := json.Unmarshal(body, &tabs); err != nil { return "" } for _, t := range tabs { if t.Type == "page" { return t.ID } } return "" } // tryAttachDebugger connects to a browser already listening on the debug port. // Returns (allocCtx, cancel, true) on success, (nil, nil, false) if no browser is there. func tryAttachDebugger(_ context.Context, port string) (context.Context, context.CancelFunc, bool) { url := fmt.Sprintf("http://localhost:%s", port) client := &http.Client{Timeout: 500 * time.Millisecond} resp, err := client.Get(url + "/json/version") if err != nil { return nil, nil, false } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil || resp.StatusCode != 200 { return nil, nil, false } var info struct { WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` } if err := json.Unmarshal(body, &info); err != nil || info.WebSocketDebuggerURL == "" { return nil, nil, false } // ponytail: background context — command timeout must not kill the persistent browser. allocCtx, cancel := chromedp.NewRemoteAllocator(context.Background(), url) return allocCtx, cancel, true } func (s *Session) Close() { // Just release the CDP connection — never close or navigate the tab. if s.cancel != nil { s.cancel() } if s.allocCancel != nil { s.allocCancel() } } func (s *Session) Navigate(url string) error { if err := chromedp.Run(s.ctx, chromedp.Navigate(url)); err != nil { return err } if err := chromedp.Run(s.ctx, chromedp.WaitVisible("body", chromedp.ByQuery)); err != nil { return err } randomDelay() return nil } func randomDelay() { delay := randomDelayMin + rand.Intn(randomDelayRange) time.Sleep(time.Duration(delay) * time.Millisecond) } func isAuthWall(url string) bool { return strings.Contains(url, "linkedin.com/login") || strings.Contains(url, "linkedin.com/checkpoint") } // findBrowserExec returns the path to Brave or Chrome, preferring Brave. // Returns "" to let chromedp use its own search if neither is found. func findBrowserExec() string { for _, p := range []string{ "/opt/brave.com/brave/brave", "/usr/bin/brave-browser", "/usr/bin/brave", "/snap/bin/brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", "/usr/bin/google-chrome", "/usr/bin/chromium-browser", "/usr/bin/chromium", } { if _, err := os.Stat(p); err == nil { return p } } return "" } // WaitForAuth blocks until the current page is no longer an auth wall. // In headless mode (no DISPLAY), returns ErrHeadlessAuth immediately. func (s *Session) WaitForAuth(ctx context.Context) error { if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { return ErrHeadlessAuth } fmt.Fprintln(os.Stderr, "linkedin: login required — complete sign-in in the browser window, then it will continue automatically") deadline := time.Now().Add(5 * time.Minute) for time.Now().Before(deadline) { select { case <-ctx.Done(): return ctx.Err() case <-time.After(3 * time.Second): } var currentURL string if err := chromedp.Run(s.ctx, chromedp.Evaluate("window.location.href", ¤tURL)); err != nil { return err } if !isAuthWall(currentURL) { fmt.Fprintln(os.Stderr, "linkedin: login detected, continuing") return nil } } return ErrAuth } type rateStore struct { Hour string `json:"hour"` Count int `json:"count"` } // loadRateStore reads a JSON file and unmarshals into v. // Returns nil if the file does not exist. func loadRateStore(path string, v any) error { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil } return err } return json.Unmarshal(data, v) } // saveRateStore atomically writes v as JSON to path with 0600 permissions. func saveRateStore(path string, v any) error { data, err := json.Marshal(v) if err != nil { return err } tmpPath := path + ".tmp" if err := os.WriteFile(tmpPath, data, 0600); err != nil { return err } return os.Rename(tmpPath, path) } func checkReplyRate() error { stateDir := filepath.Join(os.Getenv("HOME"), ".apex") os.MkdirAll(stateDir, 0700) ratePath := filepath.Join(stateDir, "linkedin-rate.json") // ponytail: global lock + flock on file. Covers rare parallel-invoke case; per-account locks if throughput matters. f, err := os.OpenFile(ratePath, os.O_CREATE|os.O_RDWR, 0600) if err != nil { return fmt.Errorf("linkedin: rate store open: %w", err) } defer f.Close() if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { return fmt.Errorf("linkedin: rate store lock: %w", err) } defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) currentHour := time.Now().Format("2006-01-02T15") var rs rateStore if err := loadRateStore(ratePath, &rs); err != nil { fmt.Fprintf(os.Stderr, "linkedin: rate store corrupt, resetting: %v\n", err) rs = rateStore{} } if rs.Hour == currentHour && rs.Count >= replyRateLimit { return ErrRateLimited } if rs.Hour != currentHour { rs.Hour = currentHour rs.Count = 0 } rs.Count++ if err := saveRateStore(ratePath, rs); err != nil { return fmt.Errorf("linkedin: rate store write: %w", err) } return nil } type inviteRecord struct { Timestamp string `json:"ts"` Vanity string `json:"vanity"` } type inviteStore struct { Invites []inviteRecord `json:"invites"` } func checkInviteRate() error { stateDir := filepath.Join(os.Getenv("HOME"), ".apex") os.MkdirAll(stateDir, 0700) ratePath := filepath.Join(stateDir, "linkedin-invite-rate.json") // ponytail: global lock + flock on file. Covers rare parallel-invoke case; per-account locks if throughput matters. f, err := os.OpenFile(ratePath, os.O_CREATE|os.O_RDWR, 0600) if err != nil { return fmt.Errorf("linkedin: invite rate store open: %w", err) } defer f.Close() if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { return fmt.Errorf("linkedin: invite rate store lock: %w", err) } defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) var store inviteStore if err := loadRateStore(ratePath, &store); err != nil { fmt.Fprintf(os.Stderr, "linkedin: invite rate store corrupt, resetting: %v\n", err) store = inviteStore{} } // Count invites within the last 7 days cutoff := time.Now().AddDate(0, 0, -7) validCount := 0 for _, rec := range store.Invites { if ts, err := time.Parse(time.RFC3339, rec.Timestamp); err == nil && ts.After(cutoff) { validCount++ } } if validCount >= inviteRateLimit { return fmt.Errorf("linkedin: invite rate limit reached (%d/wk)", inviteRateLimit) } return nil } func recordInvite(vanity string) error { stateDir := filepath.Join(os.Getenv("HOME"), ".apex") os.MkdirAll(stateDir, 0700) ratePath := filepath.Join(stateDir, "linkedin-invite-rate.json") // ponytail: global lock + flock on file. Covers rare parallel-invoke case; per-account locks if throughput matters. f, err := os.OpenFile(ratePath, os.O_CREATE|os.O_RDWR, 0600) if err != nil { return fmt.Errorf("linkedin: invite rate store open: %w", err) } defer f.Close() if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { return fmt.Errorf("linkedin: invite rate store lock: %w", err) } defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) var store inviteStore if err := loadRateStore(ratePath, &store); err != nil { store = inviteStore{} } // Append the new invite store.Invites = append(store.Invites, inviteRecord{ Timestamp: time.Now().Format(time.RFC3339), Vanity: vanity, }) if err := saveRateStore(ratePath, store); err != nil { return fmt.Errorf("linkedin: invite rate store write: %w", err) } return nil }