package sourcing import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" ) // PDLBase is the People Data Labs v5 person search endpoint. const PDLBase = "https://api.peopledatalabs.com/v5/person/search" // ErrThrottled is returned on a 429 from the sourcing provider. var ErrThrottled = errors.New("sourcing: rate limited (429)") // PDL is the People Data Labs Provider. includeContact controls whether email and phone are kept. type PDL struct { key string base string includeContact bool http *http.Client } // NewPDL builds the provider. An empty base uses the public endpoint. func NewPDL(key, base string, includeContact bool) *PDL { if strings.TrimSpace(base) == "" { base = PDLBase } return &PDL{key: key, base: base, includeContact: includeContact, http: &http.Client{Timeout: 45 * time.Second}} } func (p *PDL) Name() string { return "People Data Labs" } // Search runs one person search and normalizes the results. func (p *PDL) Search(ctx context.Context, q SearchQuery) ([]SourcedPerson, error) { size := q.Size if size < 1 { size = 10 } payload, err := json.Marshal(map[string]any{"query": q.ESQuery, "size": size}) if err != nil { return nil, fmt.Errorf("sourcing: marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.base, bytes.NewReader(payload)) if err != nil { return nil, errors.New("sourcing: failed to build request") } req.Header.Set("X-Api-Key", p.key) req.Header.Set("Content-Type", "application/json") resp, err := p.http.Do(req) if err != nil { return nil, errors.New("sourcing: request failed") } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusTooManyRequests { return nil, ErrThrottled } if resp.StatusCode != http.StatusOK { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("sourcing: status %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) } var r pdlResponse if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return nil, fmt.Errorf("sourcing: decode response: %w", err) } out := make([]SourcedPerson, 0, len(r.Data)) for _, raw := range r.Data { sp, ok := p.normalize(raw) if !ok { continue } out = append(out, sp) } return out, nil } // normalize uses map based extraction because PDL data is messy: location_name can be a boolean, // inferred_years_experience can be a float, and contact arrays vary. Type tolerant helpers keep a // single odd field from dropping an otherwise good record. func (p *PDL) normalize(raw json.RawMessage) (SourcedPerson, bool) { var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { return SourcedPerson{}, false } id := strings.TrimSpace(asString(m["id"])) name := strings.TrimSpace(asString(m["full_name"])) if id == "" || name == "" { return SourcedPerson{}, false } sp := SourcedPerson{ ExternalID: id, FullName: name, JobTitle: strings.TrimSpace(asString(m["job_title"])), Role: strings.TrimSpace(asString(m["job_title_role"])), Location: strings.TrimSpace(asString(m["location_name"])), Skills: asStringSlice(m["skills"]), Years: asInt(m["inferred_years_experience"]), Raw: raw, } if p.includeContact { sp.Emails = emailAddresses(m["emails"]) sp.Phones = asStringSlice(m["phone_numbers"]) } return sp, true } type pdlResponse struct { Status int `json:"status"` Total int `json:"total"` Data []json.RawMessage `json:"data"` Error json.RawMessage `json:"error"` } func asString(v any) string { if s, ok := v.(string); ok { return s } return "" } func asInt(v any) int { switch n := v.(type) { case float64: if n > 0 { return int(n) } case string: var i int if _, err := fmt.Sscanf(n, "%d", &i); err == nil && i > 0 { return i } } return 0 } func asStringSlice(v any) []string { arr, ok := v.([]any) if !ok { return nil } out := make([]string, 0, len(arr)) for _, e := range arr { if s, ok := e.(string); ok && s != "" { out = append(out, s) } } return out } func emailAddresses(v any) []string { arr, ok := v.([]any) if !ok { return nil } var out []string for _, e := range arr { if m, ok := e.(map[string]any); ok { if a := asString(m["address"]); a != "" { out = append(out, a) } } } return out }