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
|
||||
}
|
||||
@@ -0,0 +1,906 @@
|
||||
package linkedin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("linkedin: target not found")
|
||||
ErrSendFailed = errors.New("linkedin: failed to send message")
|
||||
ErrInviteRateLimited = errors.New("linkedin: invite rate limit reached (20/wk)")
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Author string
|
||||
Text string
|
||||
Timestamp string
|
||||
}
|
||||
|
||||
type Thread struct {
|
||||
Sender string
|
||||
URL string
|
||||
Preview string
|
||||
Messages []Message
|
||||
}
|
||||
|
||||
type Contact struct {
|
||||
Name string
|
||||
Title string
|
||||
ProfileURL string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
sess *Session
|
||||
}
|
||||
|
||||
func NewClient(ctx context.Context) (*Client, error) {
|
||||
sess, err := NewSession(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{sess: sess}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
if c.sess != nil {
|
||||
c.sess.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// navigateWithAuth navigates to targetURL and, if an auth wall is encountered,
|
||||
// waits for the user to log in then re-navigates to the target.
|
||||
func (c *Client) navigateWithAuth(ctx context.Context, targetURL string) error {
|
||||
if err := c.sess.Navigate(targetURL); err != nil {
|
||||
return err
|
||||
}
|
||||
var currentURL string
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate("window.location.href", ¤tURL)); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isAuthWall(currentURL) {
|
||||
return nil
|
||||
}
|
||||
if err := c.sess.WaitForAuth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.sess.Navigate(targetURL)
|
||||
}
|
||||
|
||||
// Login navigates to the LinkedIn login page and waits for the user to complete
|
||||
// sign-in (up to 5 minutes), then verifies the session by checking /messaging.
|
||||
// Must be called with a display available.
|
||||
func (c *Client) Login(ctx context.Context) error {
|
||||
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||
return ErrHeadlessAuth
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "linkedin: opening browser — complete sign-in, then return here")
|
||||
if err := c.sess.Navigate("https://www.linkedin.com/login"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Poll until no longer on auth wall
|
||||
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(c.sess.ctx, chromedp.Evaluate("window.location.href", ¤tURL)); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isAuthWall(currentURL) {
|
||||
fmt.Fprintln(os.Stderr, "linkedin: login detected")
|
||||
break
|
||||
}
|
||||
}
|
||||
// Verify session by navigating to messaging
|
||||
if err := c.sess.Navigate("https://www.linkedin.com/messaging"); err != nil {
|
||||
return err
|
||||
}
|
||||
var finalURL string
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate("window.location.href", &finalURL)); err != nil {
|
||||
return err
|
||||
}
|
||||
if isAuthWall(finalURL) {
|
||||
return fmt.Errorf("login did not complete — session not established")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProbeThread dumps DOM structure from a thread page to identify current selectors.
|
||||
// Use when ReadThread returns ErrNotFound after a LinkedIn DOM update.
|
||||
func (c *Client) ProbeThread(ctx context.Context, threadURL string) (string, error) {
|
||||
if err := c.navigateWithAuth(ctx, threadURL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
waitCtx, waitCancel := context.WithTimeout(c.sess.ctx, 10*time.Second)
|
||||
defer waitCancel()
|
||||
if err := chromedp.Run(waitCtx, chromedp.WaitVisible("body", chromedp.ByQuery)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var info string
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
(function() {
|
||||
const lines = [];
|
||||
// Top-level message container candidates
|
||||
['ul', 'ol', 'div', 'section'].forEach(tag => {
|
||||
document.querySelectorAll(tag).forEach(el => {
|
||||
if (el.className && /msg/.test(el.className)) {
|
||||
lines.push(tag + '.' + el.className.trim().split(/\s+/).join('.'));
|
||||
}
|
||||
});
|
||||
});
|
||||
// Sample first 5 li elements that look like messages
|
||||
document.querySelectorAll('li').forEach((el, i) => {
|
||||
if (i > 50) return;
|
||||
if (el.className && /msg/.test(el.className)) {
|
||||
const attrs = Array.from(el.attributes).map(a => a.name + '=' + a.value).join(', ');
|
||||
lines.push('li.' + el.className.trim().split(/\s+/).join('.') + ' [' + attrs + ']');
|
||||
// Child classes
|
||||
Array.from(el.querySelectorAll('*')).slice(0, 8).forEach(child => {
|
||||
if (child.className && typeof child.className === 'string') {
|
||||
lines.push(' ' + child.tagName.toLowerCase() + '.' + child.className.trim().split(/\s+/).join('.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return lines.slice(0, 80).join('\n') || '(no msg-* elements found)';
|
||||
})()
|
||||
`, &info)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (c *Client) ProbeButtons(ctx context.Context, threadURL string) (string, error) {
|
||||
if err := c.navigateWithAuth(ctx, threadURL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
var info string
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
(function(){
|
||||
var lines = [];
|
||||
document.querySelectorAll('button').forEach(function(b, i) {
|
||||
var t = b.textContent.trim().replace(/\s+/g,' ');
|
||||
var cls = b.className.trim().replace(/\s+/g,' ');
|
||||
lines.push(i + ': [' + cls + '] "' + t + '"');
|
||||
});
|
||||
// Also dump any artdeco-modal or dialog elements
|
||||
document.querySelectorAll('[role="dialog"], .artdeco-modal, .artdeco-overlay').forEach(function(el) {
|
||||
lines.push('MODAL: ' + el.tagName + '.' + el.className.trim().split(/\s+/).join('.'));
|
||||
});
|
||||
return lines.join('\n') || '(no buttons found)';
|
||||
})()
|
||||
`, &info)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// FindThread scrolls the left conversation panel looking for a participant
|
||||
// whose name matches `name` (case-insensitive substring, first match wins).
|
||||
// Clicks the matching card and returns the resulting thread URL. Hard-navigates
|
||||
// only if the tab is off the messaging app; otherwise scrolls the existing panel.
|
||||
// Scroll ceiling: 15 iterations × 2000px covers roughly the first 30-50 threads.
|
||||
func (c *Client) FindThread(ctx context.Context, name string) (string, error) {
|
||||
var currentURL string
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate("window.location.href", ¤tURL)) //nolint:errcheck
|
||||
if !strings.Contains(currentURL, "linkedin.com/messaging") {
|
||||
if err := c.navigateWithAuth(ctx, "https://www.linkedin.com/messaging"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
clickJS := fmt.Sprintf(`(function(){
|
||||
var items = Array.from(document.querySelectorAll('.msg-conversation-listitem'));
|
||||
var match = items.find(it => {
|
||||
var n = it.querySelector('.msg-conversation-listitem__participant-names')?.innerText?.trim() || '';
|
||||
return n.toLowerCase().includes(%q);
|
||||
});
|
||||
if (!match) return 'NOT_FOUND';
|
||||
var clickTarget = match.querySelector('.msg-conversation-listitem__link') || match;
|
||||
clickTarget.click();
|
||||
return 'CLICKED';
|
||||
})()`, strings.ToLower(name))
|
||||
|
||||
for i := 0; i < 15; i++ {
|
||||
var result string
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate(clickJS, &result)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result == "CLICKED" {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
var u string
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate("window.location.href", &u)) //nolint:errcheck
|
||||
if strings.Contains(u, "/messaging/thread/") {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("clicked %s but thread URL did not load", name)
|
||||
}
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
var items = document.querySelectorAll('.msg-conversation-listitem');
|
||||
if (items.length) items[items.length - 1].scrollIntoView({block: 'end', behavior: 'instant'});
|
||||
`, nil)) //nolint:errcheck
|
||||
time.Sleep(900 * time.Millisecond)
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
func (c *Client) TestSelectors(ctx context.Context) error {
|
||||
if err := c.navigateWithAuth(ctx, "https://www.linkedin.com/messaging"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for message list items with 10s timeout
|
||||
ctx, cancel := context.WithTimeout(c.sess.ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.WaitVisible(".msg-conversation-listitem", chromedp.ByQuery),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("selectors stale or not found: %w", err)
|
||||
}
|
||||
|
||||
// Verify invite flow selectors on a public profile
|
||||
if err := c.navigateWithAuth(ctx, "https://www.linkedin.com/in/williamhgates/"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Poll for at least one of the expected buttons/states
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var found bool
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
(function(){
|
||||
var buttons = Array.from(document.querySelectorAll('button'));
|
||||
// Check for Connect button
|
||||
if (buttons.some(b => b.textContent.trim() === 'Connect')) return true;
|
||||
// Check for Pending button (already-sent invite)
|
||||
if (buttons.some(b => b.textContent.trim() === 'Pending')) return true;
|
||||
// Check for button with aria-label containing Invite
|
||||
if (buttons.some(b => (b.getAttribute('aria-label') || '').includes('Invite'))) return true;
|
||||
// Check for More actions button
|
||||
if (buttons.some(b => (b.getAttribute('aria-label') || '').includes('More actions'))) return true;
|
||||
return false;
|
||||
})()
|
||||
`, &found)) //nolint:errcheck
|
||||
|
||||
if found {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
return fmt.Errorf("selectors stale: profile action buttons not found")
|
||||
}
|
||||
|
||||
func (c *Client) ListMessages(ctx context.Context) ([]Thread, error) {
|
||||
var currentURL string
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate("window.location.href", ¤tURL)) //nolint:errcheck
|
||||
if !strings.Contains(currentURL, "linkedin.com/messaging") {
|
||||
if err := c.navigateWithAuth(ctx, "https://www.linkedin.com/messaging"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
waitCtx, waitCancel := context.WithTimeout(c.sess.ctx, 15*time.Second)
|
||||
defer waitCancel()
|
||||
if err := chromedp.Run(waitCtx, chromedp.WaitVisible(".msg-conversation-listitem", chromedp.ByQuery)); err != nil {
|
||||
return nil, fmt.Errorf("linkedin: message list did not load: %w", err)
|
||||
}
|
||||
|
||||
// Scrape sender+preview in one pass.
|
||||
var rawItems []map[string]string
|
||||
if err := chromedp.Run(c.sess.ctx,
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('.msg-conversation-listitem')).map(item => ({
|
||||
sender: item.querySelector('.msg-conversation-listitem__participant-names')?.innerText?.trim() || '',
|
||||
preview: item.querySelector('.msg-conversation-listitem__message-snippet')?.innerText?.trim() ||
|
||||
item.querySelector('.msg-conversation-card__message-snippet')?.innerText?.trim() || ''
|
||||
}))
|
||||
`, &rawItems),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(rawItems) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
count := len(rawItems)
|
||||
if count > 10 {
|
||||
count = 10
|
||||
}
|
||||
|
||||
// Intercept history.pushState so URL changes are observable without polling.
|
||||
// LinkedIn's SPA uses pushState; the left panel stays mounted between clicks
|
||||
// so we never need to navigate back to /messaging.
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
window.__apexLastURL = window.location.href;
|
||||
const _orig = history.pushState.bind(history);
|
||||
history.pushState = function(state, title, url) {
|
||||
_orig(state, title, url);
|
||||
window.__apexLastURL = window.location.href;
|
||||
};
|
||||
`, nil)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var threads []Thread
|
||||
for i := 0; i < count; i++ {
|
||||
item := rawItems[i]
|
||||
if item["sender"] == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := chromedp.Run(c.sess.ctx,
|
||||
chromedp.Evaluate(fmt.Sprintf(
|
||||
`document.querySelectorAll('.msg-conversation-listitem__link')[%d].click()`, i,
|
||||
), nil),
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Wait for URL to change to a thread path.
|
||||
var threadURL string
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
if err := chromedp.Run(c.sess.ctx,
|
||||
chromedp.Evaluate("window.__apexLastURL || window.location.href", &threadURL),
|
||||
); err != nil {
|
||||
break
|
||||
}
|
||||
if strings.Contains(threadURL, "/messaging/thread/") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(threadURL, "/messaging/thread/") {
|
||||
threads = append(threads, Thread{
|
||||
Sender: item["sender"],
|
||||
URL: threadURL,
|
||||
Preview: item["preview"],
|
||||
})
|
||||
}
|
||||
|
||||
// Brief human-pace pause between clicks; no back-navigation needed.
|
||||
randomDelay()
|
||||
}
|
||||
|
||||
if len(threads) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return threads, nil
|
||||
}
|
||||
|
||||
func (c *Client) ReadThread(ctx context.Context, threadURL string) (*Thread, error) {
|
||||
var currentURL string
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate("window.location.href", ¤tURL)) //nolint:errcheck
|
||||
if !strings.Contains(currentURL, strings.TrimRight(threadURL, "/")) {
|
||||
if err := c.navigateWithAuth(ctx, threadURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
thread := &Thread{URL: threadURL}
|
||||
var result map[string]interface{}
|
||||
|
||||
// Wait for messages to load.
|
||||
waitMsgCtx, waitMsgCancel := context.WithTimeout(c.sess.ctx, 10*time.Second)
|
||||
defer waitMsgCancel()
|
||||
_ = chromedp.Run(waitMsgCtx, chromedp.WaitVisible(".msg-s-message-list__event", chromedp.ByQuery))
|
||||
|
||||
err := chromedp.Run(c.sess.ctx,
|
||||
chromedp.Evaluate(`
|
||||
({
|
||||
sender: document.querySelector('.msg-s-message-group__name')?.innerText?.trim() || 'Unknown',
|
||||
messages: Array.from(document.querySelectorAll('.msg-s-message-list__event')).map(item => ({
|
||||
author: item.querySelector('.msg-s-message-group__name')?.innerText?.trim() || '',
|
||||
text: item.querySelector('.msg-s-event-listitem__body')?.innerText?.trim() || '',
|
||||
timestamp: item.querySelector('time.msg-s-message-group__timestamp')?.innerText?.trim() ||
|
||||
item.querySelector('time.msg-s-message-list__time-heading')?.innerText?.trim() || ''
|
||||
})).filter(m => m.text !== '')
|
||||
})
|
||||
`, &result),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse result
|
||||
if sender, ok := result["sender"].(string); ok {
|
||||
thread.Sender = sender
|
||||
}
|
||||
|
||||
if msgs, ok := result["messages"].([]interface{}); ok {
|
||||
for _, msg := range msgs {
|
||||
if msgMap, ok := msg.(map[string]interface{}); ok {
|
||||
thread.Messages = append(thread.Messages, Message{
|
||||
Author: fmt.Sprint(msgMap["author"]),
|
||||
Text: fmt.Sprint(msgMap["text"]),
|
||||
Timestamp: fmt.Sprint(msgMap["timestamp"]),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(thread.Messages) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return thread, nil
|
||||
}
|
||||
|
||||
// normalizeProfileURL accepts https://www.linkedin.com/in/<vanity>[/] or
|
||||
// https://www.linkedin.com/preload/custom-invite/?vanityName=<vanity>...
|
||||
// For /in/<vanity> returns https://www.linkedin.com/in/<vanity>/
|
||||
// For preload URLs returns the preload URL unchanged to preserve modal auto-open behavior.
|
||||
func normalizeProfileURL(raw string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("linkedin: invalid profile URL: %w", err)
|
||||
}
|
||||
if u.Host != "www.linkedin.com" {
|
||||
return "", fmt.Errorf("linkedin: profile URL must be on www.linkedin.com, got %q", u.Host)
|
||||
}
|
||||
if strings.HasPrefix(u.Path, "/in/") {
|
||||
vanity := strings.TrimPrefix(u.Path, "/in/")
|
||||
vanity = strings.TrimSuffix(vanity, "/")
|
||||
if vanity == "" {
|
||||
return "", fmt.Errorf("linkedin: profile URL /in/ path is empty")
|
||||
}
|
||||
return fmt.Sprintf("https://www.linkedin.com/in/%s/", vanity), nil
|
||||
}
|
||||
path := strings.TrimSuffix(u.Path, "/")
|
||||
if path == "/preload/custom-invite" {
|
||||
vanityName := u.Query().Get("vanityName")
|
||||
if vanityName == "" {
|
||||
return "", fmt.Errorf("linkedin: preload URL missing vanityName query param")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
return "", fmt.Errorf("linkedin: unrecognized profile URL format - use /in/<vanity> or /preload/custom-invite/?vanityName=<vanity>")
|
||||
}
|
||||
|
||||
// normalizeText replaces em and en dashes with standard hyphens.
|
||||
func normalizeText(s string) string {
|
||||
s = strings.ReplaceAll(s, "—", " - ")
|
||||
s = strings.ReplaceAll(s, "–", " - ")
|
||||
return s
|
||||
}
|
||||
|
||||
func (c *Client) Reply(ctx context.Context, threadURL, message string) error {
|
||||
if err := checkReplyRate(); err != nil {
|
||||
return err
|
||||
}
|
||||
message = normalizeText(message)
|
||||
// Only navigate if we're not already on this thread — navigating triggers the
|
||||
// "Share your contact info?" InMail dialog which blocks the compose box.
|
||||
var currentURL string
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate("window.location.href", ¤tURL)); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(currentURL, strings.TrimRight(threadURL, "/")) {
|
||||
if err := c.navigateWithAuth(ctx, threadURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Click "No, don't share" if the InMail contact-info dialog is showing.
|
||||
// Poll up to 2s so the dialog has time to render after navigation.
|
||||
dialogDeadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(dialogDeadline) {
|
||||
var clicked bool
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
(function(){
|
||||
var spans = Array.from(document.querySelectorAll("button span"));
|
||||
var hit = spans.find(s => {
|
||||
var t = s.textContent.trim().replace(/\s+/g,’ ‘);
|
||||
return t.startsWith("No,") && t.includes("share");
|
||||
});
|
||||
if (hit) { hit.closest("button").click(); return true; }
|
||||
return false;
|
||||
})()
|
||||
`, &clicked)) //nolint:errcheck
|
||||
if clicked {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
waitCtx, waitCancel := context.WithTimeout(c.sess.ctx, 10*time.Second)
|
||||
defer waitCancel()
|
||||
if err := chromedp.Run(waitCtx, chromedp.WaitVisible(".msg-form__contenteditable", chromedp.ByQuery)); err != nil {
|
||||
return ErrSendFailed
|
||||
}
|
||||
|
||||
// Focus then inject via paste event. SendKeys types visually but doesn't update React's
|
||||
// internal state (send button stays disabled). Paste event does update it.
|
||||
// ponytail: %q gives a Go-quoted string that is also valid JS — handles all escaping.
|
||||
pasteJS := fmt.Sprintf(`(function(){
|
||||
var box = document.querySelector('.msg-form__contenteditable');
|
||||
box.focus();
|
||||
var dt = new DataTransfer();
|
||||
dt.setData('text/plain', %s);
|
||||
box.dispatchEvent(new ClipboardEvent('paste', {bubbles:true, cancelable:true, clipboardData:dt}));
|
||||
})()`, fmt.Sprintf("%q", message))
|
||||
|
||||
if err := chromedp.Run(c.sess.ctx,
|
||||
chromedp.Click(".msg-form__contenteditable", chromedp.ByQuery),
|
||||
chromedp.Evaluate(pasteJS, nil),
|
||||
); err != nil {
|
||||
return ErrSendFailed
|
||||
}
|
||||
|
||||
// Wait for send button to enable (React processes the paste event asynchronously).
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
var buttonEnabled bool
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
var disabled bool
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate(
|
||||
`document.querySelector('.msg-form__send-button')?.disabled ?? true`, &disabled,
|
||||
)); err != nil {
|
||||
break
|
||||
}
|
||||
if !disabled {
|
||||
buttonEnabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !buttonEnabled {
|
||||
return fmt.Errorf("linkedin: send button did not enable — paste did not update React state")
|
||||
}
|
||||
|
||||
// Submit via the form's native submit mechanism — avoids coordinate hit-testing
|
||||
// (which can miss if a recaptcha iframe overlays the button) and works even when
|
||||
// the contenteditable doesn't have focus.
|
||||
var submitted bool
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
(function(){
|
||||
var btn = document.querySelector('.msg-form__send-button');
|
||||
if (btn && btn.form) { btn.form.requestSubmit(btn); return true; }
|
||||
if (btn) { btn.click(); return true; }
|
||||
return false;
|
||||
})()
|
||||
`, &submitted)); err != nil || !submitted {
|
||||
return ErrSendFailed
|
||||
}
|
||||
|
||||
randomDelay()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) SendInvite(ctx context.Context, profileURL, note string) error {
|
||||
if len(note) > 200 {
|
||||
return fmt.Errorf("linkedin: invite note exceeds 200 chars (LinkedIn free-tier limit)")
|
||||
}
|
||||
if err := checkInviteRate(); err != nil {
|
||||
return err
|
||||
}
|
||||
profileURL, err := normalizeProfileURL(profileURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
note = normalizeText(note)
|
||||
|
||||
if err := c.navigateWithAuth(ctx, profileURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if the connect modal is already open
|
||||
// For preload URLs, wait briefly for the modal to auto-open
|
||||
var modalOpen bool
|
||||
if strings.Contains(profileURL, "/preload/custom-invite") {
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
(function(){
|
||||
var modal = document.querySelector('[role="dialog"]');
|
||||
if (modal && (modal.innerHTML.includes('Add a note') || modal.classList.contains('artdeco-modal'))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
`, &modalOpen)) //nolint:errcheck
|
||||
if modalOpen {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
if !modalOpen {
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
(function(){
|
||||
var modal = document.querySelector('[role="dialog"]');
|
||||
if (modal && (modal.innerHTML.includes('Add a note') || modal.classList.contains('artdeco-modal'))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
`, &modalOpen)) //nolint:errcheck
|
||||
}
|
||||
|
||||
if !modalOpen {
|
||||
// Try to find and click the Connect button (top-level or under More menu)
|
||||
var connectClicked bool
|
||||
// First try the simple case: button with aria-label containing "Invite" and text "Connect"
|
||||
connectJS := `(function(){
|
||||
var buttons = Array.from(document.querySelectorAll('button'));
|
||||
var connectBtn = buttons.find(b => {
|
||||
var aria = b.getAttribute('aria-label') || '';
|
||||
var text = b.textContent.trim();
|
||||
return aria.includes('Invite') && text === 'Connect';
|
||||
});
|
||||
if (connectBtn) { connectBtn.click(); return true; }
|
||||
|
||||
// Fallback: button whose visible text is exactly "Connect"
|
||||
connectBtn = buttons.find(b => b.textContent.trim() === 'Connect');
|
||||
if (connectBtn) { connectBtn.click(); return true; }
|
||||
|
||||
// Try to find and click More menu, then Connect in dropdown
|
||||
var moreBtn = buttons.find(b => b.textContent.trim() === 'More' || (b.getAttribute('aria-label') || '').includes('More actions'));
|
||||
if (moreBtn) {
|
||||
moreBtn.click();
|
||||
// Small delay for dropdown to render
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
})()`
|
||||
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate(connectJS, &connectClicked)); err == nil && connectClicked {
|
||||
// Modal should open; wait for it
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
} else {
|
||||
// Try clicking Connect in the dropdown (after More was clicked)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
var connectInDropdown bool
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
(function(){
|
||||
var buttons = Array.from(document.querySelectorAll('[role="menuitem"], button'));
|
||||
var connectBtn = buttons.find(b => b.textContent.trim() === 'Connect');
|
||||
if (connectBtn) { connectBtn.click(); return true; }
|
||||
return false;
|
||||
})()
|
||||
`, &connectInDropdown)) //nolint:errcheck
|
||||
if !connectInDropdown {
|
||||
return fmt.Errorf("linkedin: 'Connect' button not found at %s - LinkedIn DOM may have changed", profileURL)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
// Wait for the "Add a note" button to be visible/clickable
|
||||
noteDeadline := time.Now().Add(5 * time.Second)
|
||||
var noteButtonFound bool
|
||||
for time.Now().Before(noteDeadline) {
|
||||
var found bool
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button, span')).some(el => {
|
||||
var t = el.textContent.trim();
|
||||
return t === 'Add a note' && el.closest('button');
|
||||
})
|
||||
`, &found)) //nolint:errcheck
|
||||
if found {
|
||||
noteButtonFound = true
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if !noteButtonFound {
|
||||
return fmt.Errorf("linkedin: 'Add a note' button not found at %s - LinkedIn DOM may have changed", profileURL)
|
||||
}
|
||||
|
||||
// Click "Add a note"
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Add a note').click()
|
||||
`, nil)) //nolint:errcheck
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Wait for any textarea in the modal (LinkedIn varies: textarea[name=message], #custom-message, or unlabeled).
|
||||
textareaDeadline := time.Now().Add(5 * time.Second)
|
||||
var textareaFound bool
|
||||
for time.Now().Before(textareaDeadline) {
|
||||
var found bool
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate(`(function(){
|
||||
var modal=document.querySelector('[role="dialog"],.artdeco-modal');
|
||||
var scope=modal||document;
|
||||
return !!scope.querySelector('textarea');
|
||||
})()`, &found)) //nolint:errcheck
|
||||
if found {
|
||||
textareaFound = true
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if !textareaFound {
|
||||
return fmt.Errorf("linkedin: message textarea not found in invite modal")
|
||||
}
|
||||
|
||||
// ponytail: React-controlled textareas ignore plain .value writes and paste events.
|
||||
// Use the native setter + bubbling input event so React's onChange picks up the value.
|
||||
pasteJS := fmt.Sprintf(`(function(){
|
||||
var modal = document.querySelector('[role="dialog"],.artdeco-modal');
|
||||
var scope = modal || document;
|
||||
var box = scope.querySelector('textarea[name="message"]') ||
|
||||
scope.querySelector('#custom-message') ||
|
||||
scope.querySelector('textarea');
|
||||
if (!box) { return false; }
|
||||
box.focus();
|
||||
var setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
||||
setter.call(box, %s);
|
||||
box.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
box.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
return box.value.length;
|
||||
})()`, fmt.Sprintf("%q", note))
|
||||
|
||||
var pastedLen int
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate(pasteJS, &pastedLen)); err != nil || pastedLen == 0 {
|
||||
return fmt.Errorf("linkedin: failed to paste note into textarea (pasted len=%d)", pastedLen)
|
||||
}
|
||||
|
||||
// LinkedIn invite modal uses aria-label "Send" or "Send invitation"; older flows use "Send now".
|
||||
// Match any primary button inside the modal whose text/aria starts with "Send" and isn't "Send without a note".
|
||||
sendBtnJS := `(function(){
|
||||
var modal = document.querySelector('[role="dialog"],.artdeco-modal');
|
||||
var scope = modal || document;
|
||||
var btns = Array.from(scope.querySelectorAll('button'));
|
||||
return btns.find(function(b){
|
||||
var aria = (b.getAttribute('aria-label') || '').trim();
|
||||
var text = b.textContent.trim();
|
||||
if (/without a note/i.test(aria) || /without a note/i.test(text)) return false;
|
||||
if (/^Send( invitation| now)?$/i.test(aria)) return true;
|
||||
if (/^Send( invitation| now)?$/i.test(text)) return true;
|
||||
return false;
|
||||
}) || null;
|
||||
})()`
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
var buttonEnabled bool
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
var disabled bool
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate(
|
||||
`(function(){var btn=`+sendBtnJS+`;return (btn===null)?true:!!btn.disabled;})()`, &disabled,
|
||||
)); err != nil {
|
||||
break
|
||||
}
|
||||
if !disabled {
|
||||
buttonEnabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !buttonEnabled {
|
||||
var dump string
|
||||
_ = chromedp.Run(c.sess.ctx, chromedp.Evaluate(`(function(){
|
||||
var m=document.querySelector('[role="dialog"],.artdeco-modal');
|
||||
if(!m) return 'NO_MODAL';
|
||||
var out=[];
|
||||
m.querySelectorAll('button,textarea').forEach(function(el){
|
||||
out.push(el.tagName+' aria="'+(el.getAttribute('aria-label')||'')+'" text="'+el.textContent.trim().slice(0,50)+'" disabled='+el.disabled+' value-len='+((el.value||'').length));
|
||||
});
|
||||
return out.join('\n');
|
||||
})()`, &dump))
|
||||
return fmt.Errorf("linkedin: send button did not enable\n%s", dump)
|
||||
}
|
||||
|
||||
// Click Send
|
||||
var sent bool
|
||||
if err := chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
(function(){
|
||||
var btn = `+sendBtnJS+`;
|
||||
if (btn && btn.form) { btn.form.requestSubmit(btn); return true; }
|
||||
if (btn) { btn.click(); return true; }
|
||||
return false;
|
||||
})()
|
||||
`, &sent)); err != nil || !sent {
|
||||
return fmt.Errorf("linkedin: failed to click Send button")
|
||||
}
|
||||
|
||||
// Verify success by polling for modal close or Pending state
|
||||
if err := c.verifyInviteSent(ctx, profileURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
randomDelay()
|
||||
if err := recordInvite(extractVanity(profileURL)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "linkedin: failed to record invite: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyInviteSent polls for modal close or Pending state to confirm the invite was sent.
|
||||
func (c *Client) verifyInviteSent(ctx context.Context, profileURL string) error {
|
||||
verifyDeadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(verifyDeadline) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
var modalClosed bool
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
!document.querySelector('[role="dialog"]')
|
||||
`, &modalClosed)) //nolint:errcheck
|
||||
if modalClosed {
|
||||
return nil
|
||||
}
|
||||
var hasPending bool
|
||||
chromedp.Run(c.sess.ctx, chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button')).some(b => b.textContent.trim() === 'Pending')
|
||||
`, &hasPending)) //nolint:errcheck
|
||||
if hasPending {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("linkedin: invite verification timed out at %s", profileURL)
|
||||
}
|
||||
|
||||
// extractVanity extracts the vanity name from a normalized profile URL.
|
||||
// Assumes URL is in format https://www.linkedin.com/in/<vanity>/
|
||||
func extractVanity(normalizedURL string) string {
|
||||
parts := strings.Split(strings.TrimSuffix(normalizedURL, "/"), "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Client) FindContacts(ctx context.Context, company string) ([]Contact, error) {
|
||||
// Build search query
|
||||
query := fmt.Sprintf(`site:linkedin.com/in recruiter OR "hiring manager" "%s"`, company)
|
||||
searchURL := fmt.Sprintf("https://www.linkedin.com/search/results/people/?keywords=%s",
|
||||
url.QueryEscape(query))
|
||||
|
||||
if err := c.navigateWithAuth(ctx, searchURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var contacts []Contact
|
||||
var result []map[string]string
|
||||
|
||||
err := chromedp.Run(c.sess.ctx,
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('.entity-result__item')).slice(0, 10).map(item => ({
|
||||
name: item.querySelector('.entity-result__title-text')?.innerText || '',
|
||||
title: item.querySelector('.entity-result__primary-subtitle')?.innerText || '',
|
||||
profileURL: item.querySelector('a.app-aware-link')?.href || ''
|
||||
}))
|
||||
`, &result),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, r := range result {
|
||||
if r["name"] != "" && r["profileURL"] != "" {
|
||||
contacts = append(contacts, Contact{
|
||||
Name: r["name"],
|
||||
Title: r["title"],
|
||||
ProfileURL: r["profileURL"],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(contacts) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return contacts, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package linkedin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeProfileURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "standard /in/vanity",
|
||||
input: "https://www.linkedin.com/in/bradlittle",
|
||||
want: "https://www.linkedin.com/in/bradlittle/",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "standard /in/vanity with trailing slash",
|
||||
input: "https://www.linkedin.com/in/bradlittle/",
|
||||
want: "https://www.linkedin.com/in/bradlittle/",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "preload URL without trailing slash",
|
||||
input: "https://www.linkedin.com/preload/custom-invite/?vanityName=bradlittle",
|
||||
want: "https://www.linkedin.com/preload/custom-invite/?vanityName=bradlittle",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "preload URL with trailing slash (bug case)",
|
||||
input: "https://www.linkedin.com/preload/custom-invite/?vanityName=bradlittle",
|
||||
want: "https://www.linkedin.com/preload/custom-invite/?vanityName=bradlittle",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizeProfileURL(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("normalizeProfileURL() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeProfileURL() got = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package linkedin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
// JobResult represents a single job search result from LinkedIn.
|
||||
type JobResult struct {
|
||||
Title string
|
||||
Company string
|
||||
Location string
|
||||
URL string
|
||||
Posted string
|
||||
}
|
||||
|
||||
// SearchJobs performs a LinkedIn job search for the given keywords and location.
|
||||
// If remoteOnly is true, filters to remote positions only.
|
||||
// Returns up to 25 results from the first page of search results.
|
||||
func (c *Client) SearchJobs(ctx context.Context, keywords, location string, remoteOnly bool) ([]JobResult, error) {
|
||||
searchURL := buildJobSearchURL(keywords, location, remoteOnly)
|
||||
|
||||
if err := c.navigateWithAuth(ctx, searchURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
randomDelay()
|
||||
|
||||
// Wait for job cards to appear
|
||||
ctx, cancel := context.WithTimeout(c.sess.ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Wait for the current LinkedIn results list (data-occludable-job-id is the
|
||||
// stable post-2025 LinkedIn jobs DOM marker).
|
||||
if err := chromedp.Run(ctx,
|
||||
chromedp.WaitVisible(`li[data-occludable-job-id]`, chromedp.ByQuery),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("linkedin: no job results loaded: %w", err)
|
||||
}
|
||||
|
||||
results, err := extractJobResults(ctx, c.sess)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// buildJobSearchURL constructs a LinkedIn jobs search URL with the given filters.
|
||||
func buildJobSearchURL(keywords, location string, remoteOnly bool) string {
|
||||
baseURL := "https://www.linkedin.com/jobs/search/?"
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("keywords", keywords)
|
||||
params.Add("location", location)
|
||||
|
||||
if remoteOnly {
|
||||
params.Add("f_WT", "2")
|
||||
}
|
||||
|
||||
return baseURL + params.Encode()
|
||||
}
|
||||
|
||||
// extractJobResults extracts job listing data from the current page.
|
||||
func extractJobResults(ctx context.Context, session *Session) ([]JobResult, error) {
|
||||
var results []JobResult
|
||||
var jobsData []map[string]string
|
||||
|
||||
// Get job card data via JavaScript evaluation.
|
||||
// New LinkedIn DOM (2025+): li[data-occludable-job-id] with
|
||||
// a.job-card-container__link (title via aria-label, href is the job URL),
|
||||
// then sibling spans for company and location.
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('li[data-occludable-job-id]')).slice(0, 25).map(card => {
|
||||
var link = card.querySelector('a.job-card-container__link, a.job-card-list__title');
|
||||
var title = link?.getAttribute('aria-label') || link?.innerText?.trim() || '';
|
||||
// Strip duplicate title (LinkedIn renders strong + visually-hidden copies).
|
||||
var halves = title.split(/\s{2,}|\n/).map(s => s.trim()).filter(Boolean);
|
||||
if (halves.length >= 2 && halves[0] === halves[1]) title = halves[0];
|
||||
var href = link?.href || '';
|
||||
// Company + location: collect visible spans OUTSIDE the title link.
|
||||
var spans = Array.from(card.querySelectorAll('span'))
|
||||
.filter(s => !s.className.toString().includes('visually-hidden'))
|
||||
.filter(s => !link || !link.contains(s))
|
||||
.map(s => s.innerText.trim())
|
||||
.filter(t => t && t !== title && !/^(Promoted|Easy Apply|Viewed|Applied|·|Actively reviewing|with verification)$/i.test(t));
|
||||
var dedup = [];
|
||||
spans.forEach(t => { if (dedup[dedup.length-1] !== t) dedup.push(t); });
|
||||
var company = dedup.find(t => !/Remote|Hybrid|On-site|United States/i.test(t) && !/^\d/.test(t) && t.length < 80) || '';
|
||||
var location = dedup.find(t => /Remote|Hybrid|On-site|,\s*[A-Z]{2}|United States/i.test(t)) || '';
|
||||
return { title: title, company: company, location: location, url: href, posted: '' };
|
||||
})
|
||||
`, &jobsData),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert to JobResult structs
|
||||
for _, data := range jobsData {
|
||||
if data["title"] != "" || data["company"] != "" {
|
||||
results = append(results, JobResult{
|
||||
Title: data["title"],
|
||||
Company: data["company"],
|
||||
Location: data["location"],
|
||||
URL: data["url"],
|
||||
Posted: data["posted"],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package linkedin
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MessageType classifies the type of recruiter message.
|
||||
type MessageType int
|
||||
|
||||
const (
|
||||
// MsgTypeVague indicates a generic "are you open?" style message.
|
||||
MsgTypeVague MessageType = iota
|
||||
// MsgTypeSpecific indicates a message with role title and details.
|
||||
MsgTypeSpecific
|
||||
// MsgTypeFollowUp indicates a follow-up on a previous conversation.
|
||||
MsgTypeFollowUp
|
||||
// MsgTypeSpam indicates mass outreach or generic spam.
|
||||
MsgTypeSpam
|
||||
)
|
||||
|
||||
// ClassifyMessage classifies a recruiter message thread using heuristic patterns.
|
||||
// No LLM call — purely pattern-based for speed.
|
||||
func ClassifyMessage(thread Thread) MessageType {
|
||||
if len(thread.Messages) == 0 {
|
||||
return MsgTypeSpam
|
||||
}
|
||||
|
||||
lastMsg := thread.Messages[len(thread.Messages)-1]
|
||||
text := strings.ToLower(lastMsg.Text)
|
||||
|
||||
// Check for follow-up pattern if thread has multiple messages
|
||||
if len(thread.Messages) > 1 && isFollowUpMessage(text) {
|
||||
return MsgTypeFollowUp
|
||||
}
|
||||
|
||||
// Check for spam indicators
|
||||
if isSpamMessage(text) {
|
||||
return MsgTypeSpam
|
||||
}
|
||||
|
||||
// Check for specific role details (title + salary/location)
|
||||
if hasJobTitle(text) && (hasSalaryKeywords(text) || hasLocationKeywords(text)) {
|
||||
return MsgTypeSpecific
|
||||
}
|
||||
|
||||
// Check for vague "open to opportunities" pattern
|
||||
if isVagueMessage(text) {
|
||||
return MsgTypeVague
|
||||
}
|
||||
|
||||
// Default: if we have a job title but no salary/location, still specific
|
||||
if hasJobTitle(text) {
|
||||
return MsgTypeSpecific
|
||||
}
|
||||
|
||||
// Default to vague if none of the above
|
||||
return MsgTypeVague
|
||||
}
|
||||
|
||||
// MissingFields returns a list of standard fields that are absent from the thread.
|
||||
// Checks for: title, company, compensation, remote, location.
|
||||
func MissingFields(thread Thread) []string {
|
||||
combinedText := ""
|
||||
for _, msg := range thread.Messages {
|
||||
combinedText += " " + msg.Text + " "
|
||||
}
|
||||
combinedText = strings.ToLower(combinedText)
|
||||
|
||||
var missing []string
|
||||
|
||||
if !hasJobTitle(combinedText) {
|
||||
missing = append(missing, "title")
|
||||
}
|
||||
|
||||
if !hasCompanyName(combinedText) {
|
||||
missing = append(missing, "company")
|
||||
}
|
||||
|
||||
if !hasSalaryKeywords(combinedText) {
|
||||
missing = append(missing, "compensation")
|
||||
}
|
||||
|
||||
if !hasRemotePolicy(combinedText) {
|
||||
missing = append(missing, "remote")
|
||||
}
|
||||
|
||||
if !hasLocationKeywords(combinedText) {
|
||||
missing = append(missing, "location")
|
||||
}
|
||||
|
||||
return missing
|
||||
}
|
||||
|
||||
// DraftInfoRequest creates a polite reply requesting missing information.
|
||||
func DraftInfoRequest(thread Thread, missingFields []string) string {
|
||||
senderFirstName := extractFirstName(thread.Sender)
|
||||
|
||||
draft := "Hi " + senderFirstName + ",\n\n"
|
||||
draft += "Thanks for reaching out! I'm selectively open to the right opportunity. "
|
||||
draft += "Could you share a few more details?\n\n"
|
||||
|
||||
for _, field := range missingFields {
|
||||
switch field {
|
||||
case "title":
|
||||
draft += "- What's the role title and team?\n"
|
||||
case "company":
|
||||
draft += "- Which company is this for?\n"
|
||||
case "compensation":
|
||||
draft += "- What's the compensation range?\n"
|
||||
case "remote":
|
||||
draft += "- Is the role remote, hybrid, or on-site?\n"
|
||||
case "location":
|
||||
draft += "- Where is the role based?\n"
|
||||
}
|
||||
}
|
||||
|
||||
draft += "\nLooking forward to hearing more.\n"
|
||||
|
||||
return draft
|
||||
}
|
||||
|
||||
// isVagueMessage checks for vague recruiter phrases without job title.
|
||||
func isVagueMessage(text string) bool {
|
||||
vaguePatterns := []string{
|
||||
"open to opportunities",
|
||||
"open to the right role",
|
||||
"are you open",
|
||||
"interested in talking",
|
||||
"i came across your profile",
|
||||
"i saw your profile",
|
||||
"reach out",
|
||||
"connect with you",
|
||||
"let's connect",
|
||||
"would love to chat",
|
||||
"great profile",
|
||||
"impressed with your profile",
|
||||
}
|
||||
|
||||
for _, pattern := range vaguePatterns {
|
||||
if strings.Contains(text, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isFollowUpMessage checks for follow-up indicators.
|
||||
func isFollowUpMessage(text string) bool {
|
||||
followUpPatterns := []string{
|
||||
"following up",
|
||||
"just checking in",
|
||||
"any update",
|
||||
"any thoughts",
|
||||
"did you have a chance",
|
||||
"circle back",
|
||||
"wanted to follow up",
|
||||
}
|
||||
|
||||
for _, pattern := range followUpPatterns {
|
||||
if strings.Contains(text, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isSpamMessage checks for spam indicators.
|
||||
func isSpamMessage(text string) bool {
|
||||
// Very short message is often spam
|
||||
if len(strings.Fields(text)) < 5 {
|
||||
return true
|
||||
}
|
||||
|
||||
spamPatterns := []string{
|
||||
"i came across your profile",
|
||||
"i saw your profile",
|
||||
}
|
||||
|
||||
for _, pattern := range spamPatterns {
|
||||
if strings.Contains(text, pattern) {
|
||||
// Check if there's actual job detail after the pattern
|
||||
if !hasJobTitle(text) && !hasSalaryKeywords(text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// hasJobTitle checks for standard job title patterns.
|
||||
func hasJobTitle(text string) bool {
|
||||
// Match patterns like "Senior Engineer", "Lead Developer", "Principal Manager"
|
||||
titlePattern := regexp.MustCompile(
|
||||
`(?i)\b(senior|staff|principal|lead|junior|mid-level|entry-level|manager|director|head|vp|vice president)\s+(engineer|developer|manager|analyst|architect|lead|coordinator|specialist|consultant|scientist|designer)\b`,
|
||||
)
|
||||
return titlePattern.MatchString(text)
|
||||
}
|
||||
|
||||
// hasSalaryKeywords checks for compensation-related keywords.
|
||||
func hasSalaryKeywords(text string) bool {
|
||||
salaryKeywords := []string{
|
||||
"salary",
|
||||
"compensation",
|
||||
"comp",
|
||||
"/year",
|
||||
"k/year",
|
||||
"/annually",
|
||||
"annual",
|
||||
"signing bonus",
|
||||
"equity",
|
||||
"stock options",
|
||||
"benefits",
|
||||
}
|
||||
|
||||
for _, keyword := range salaryKeywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for dollar amounts: $XXXk, $XXX,XXX, etc.
|
||||
dollarPattern := regexp.MustCompile(`\$[\d,]+`)
|
||||
return dollarPattern.MatchString(text)
|
||||
}
|
||||
|
||||
// hasRemotePolicy checks for work location policy keywords.
|
||||
func hasRemotePolicy(text string) bool {
|
||||
remoteKeywords := []string{
|
||||
"remote",
|
||||
"hybrid",
|
||||
"on-site",
|
||||
"on-site",
|
||||
"in-office",
|
||||
"office",
|
||||
"wfh",
|
||||
"work from home",
|
||||
"distributed",
|
||||
}
|
||||
|
||||
for _, keyword := range remoteKeywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// hasLocationKeywords checks for geographic location mentions.
|
||||
func hasLocationKeywords(text string) bool {
|
||||
// Simple heuristic: look for US state abbreviations or common city patterns
|
||||
statePattern := regexp.MustCompile(
|
||||
`(?i)\b(ca|ny|tx|fl|il|pa|oh|ga|nc|mi|nj|va|wa|az|ma|tn|in|md|mo|wi|co|mn|sc|al|la|ky|or|ok|ct|ut|ia|nv|ar|ms|kansas|new york|california|texas|florida|illinois|pennsylvania|ohio|georgia|north carolina|michigan|new jersey|virginia|washington|arizona|massachusetts|tennessee|indiana|maryland|missouri|wisconsin|colorado|minnesota|south carolina|alabama|louisiana|kentucky|oregon|oklahoma|connecticut|utah|iowa|nevada|arkansas|mississippi)\b`,
|
||||
)
|
||||
|
||||
if statePattern.MatchString(text) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for major cities or country names
|
||||
cityPattern := regexp.MustCompile(
|
||||
`(?i)\b(new york|los angeles|chicago|boston|san francisco|seattle|denver|austin|miami|atlanta|london|toronto|vancouver|sydney|berlin|dubai|singapore|tokyo|india|uk|ireland|canada|australia)\b`,
|
||||
)
|
||||
|
||||
return cityPattern.MatchString(text)
|
||||
}
|
||||
|
||||
// hasCompanyName checks if a company name is mentioned.
|
||||
// Simple heuristic: looks for capitalized words after "at " or "with ".
|
||||
func hasCompanyName(text string) bool {
|
||||
// Check for explicit "at [Company]" pattern
|
||||
atPattern := regexp.MustCompile(`(?i)\bat\s+([A-Z][a-zA-Z0-9\s&-]+)`)
|
||||
if atPattern.MatchString(text) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for "with [Company]" pattern
|
||||
withPattern := regexp.MustCompile(`(?i)\bwith\s+([A-Z][a-zA-Z0-9\s&-]+)`)
|
||||
if withPattern.MatchString(text) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractFirstName extracts the first name from a full name string.
|
||||
func extractFirstName(fullName string) string {
|
||||
fullName = strings.TrimSpace(fullName)
|
||||
parts := strings.Fields(fullName)
|
||||
if len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
return "there"
|
||||
}
|
||||
Reference in New Issue
Block a user