907 lines
30 KiB
Go
907 lines
30 KiB
Go
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
|
||
}
|