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 }