initial public release

This commit is contained in:
2026-07-06 11:05:50 -04:00
commit ca518c5f8a
94 changed files with 15699 additions and 0 deletions
+452
View File
@@ -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", &currentURL)); 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
}
+906
View File
@@ -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", &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
}
+52
View File
@@ -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)
}
})
}
}
+117
View File
@@ -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
}
+298
View File
@@ -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"
}