Files
apex-public/internal/linkedin/client.go
T
2026-07-06 11:05:50 -04:00

907 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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", &currentURL)); 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", &currentURL)); 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", &currentURL)) //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", &currentURL)) //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", &currentURL)) //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", &currentURL)); 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
}