initial public release
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user