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/[/] or // https://www.linkedin.com/preload/custom-invite/?vanityName=... // For /in/ returns https://www.linkedin.com/in// // 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/ or /preload/custom-invite/?vanityName=") } // 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// 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 }