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

118 lines
3.9 KiB
Go

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
}