initial public release
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user