@coldiq/mcp 0.1.18 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +7 -1
- package/dist/client.js.map +1 -1
- package/dist/executor.d.ts +11 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +72 -11
- package/dist/executor.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +57 -8
- package/dist/registry.js.map +1 -1
- package/dist/tools/find-emails.d.ts +2 -7
- package/dist/tools/find-emails.d.ts.map +1 -1
- package/dist/tools/find-emails.js +193 -67
- package/dist/tools/find-emails.js.map +1 -1
- package/dist/tools/find-people.d.ts +3 -2
- package/dist/tools/find-people.d.ts.map +1 -1
- package/dist/tools/find-people.js +65 -7
- package/dist/tools/find-people.js.map +1 -1
- package/dist/tools/get-credit-balance.d.ts +17 -0
- package/dist/tools/get-credit-balance.d.ts.map +1 -0
- package/dist/tools/get-credit-balance.js +20 -0
- package/dist/tools/get-credit-balance.js.map +1 -0
- package/dist/utils/compact-people.d.ts +24 -0
- package/dist/utils/compact-people.d.ts.map +1 -0
- package/dist/utils/compact-people.js +306 -0
- package/dist/utils/compact-people.js.map +1 -0
- package/dist/utils/provider-resolver.d.ts.map +1 -1
- package/dist/utils/provider-resolver.js +15 -1
- package/dist/utils/provider-resolver.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +9 -1
- package/src/executor.ts +89 -17
- package/src/index.ts +8 -0
- package/src/registry.ts +67 -8
- package/src/tools/find-emails.ts +251 -80
- package/src/tools/find-people.ts +70 -7
- package/src/tools/get-credit-balance.ts +24 -0
- package/src/utils/compact-people.ts +318 -0
- package/src/utils/provider-resolver.ts +15 -1
- package/tests/executor.test.ts +165 -0
- package/tests/live/fullenrich-upstream-probe.ts +55 -0
- package/tests/live/pdl-upstream-probe.ts +83 -0
- package/tests/registry-find-people.test.ts +198 -7
- package/tests/registry-search-companies.test.ts +46 -7
- package/tests/tools/find-emails.test.ts +267 -1
- package/tests/tools/find-people.test.ts +269 -5
- package/tests/tools/get-credit-balance.test.ts +56 -0
- package/tests/utils/compact-people.test.ts +462 -0
package/src/executor.ts
CHANGED
|
@@ -8,12 +8,27 @@ export interface ExecutionResult {
|
|
|
8
8
|
provider: string
|
|
9
9
|
latencyMs: number
|
|
10
10
|
matchedFrom?: Record<string, string>
|
|
11
|
+
/** Net ColdIQ credits charged for this call (parsed from `X-ColdIQ-Credits-Charged`). */
|
|
12
|
+
credits_charged?: number
|
|
13
|
+
/** ColdIQ credit balance remaining after this call (parsed from `X-ColdIQ-Credits-Remaining`). */
|
|
14
|
+
credits_remaining?: number
|
|
11
15
|
}
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
export interface ExecutionError {
|
|
15
19
|
error: string
|
|
16
|
-
providers_tried: Array<{
|
|
20
|
+
providers_tried: Array<{
|
|
21
|
+
provider: string
|
|
22
|
+
status: number
|
|
23
|
+
error: string
|
|
24
|
+
/**
|
|
25
|
+
* Structured upstream provider response body, when available. Lets callers
|
|
26
|
+
* see validation detail (e.g. Prospeo `filter_error`) that would otherwise
|
|
27
|
+
* be lost when the short `error` string is built. Capped at ~2KB to protect
|
|
28
|
+
* against unbounded provider payloads.
|
|
29
|
+
*/
|
|
30
|
+
upstream_error?: unknown
|
|
31
|
+
}>
|
|
17
32
|
/** Set when at least one provider returned 402 — points the user at the top-up page. */
|
|
18
33
|
billingUrl?: string
|
|
19
34
|
/** True when every attempted provider failed with 402 (the user is fully blocked on credits). */
|
|
@@ -42,6 +57,32 @@ function debug(msg: string): void {
|
|
|
42
57
|
}
|
|
43
58
|
}
|
|
44
59
|
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Upstream error cap
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const MAX_UPSTREAM_ERROR_BYTES = 2048
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Defensive cap on upstream_error payloads. The API already caps `details` at
|
|
68
|
+
* 2KB, but pre-migration providers may surface their full `data` object here —
|
|
69
|
+
* keep this guard so a misbehaving upstream can't blow up the MCP response.
|
|
70
|
+
* Falls back to a truncated string repr when the JSON serialization is over
|
|
71
|
+
* the cap (rather than emitting malformed JSON).
|
|
72
|
+
*/
|
|
73
|
+
function capUpstreamError(u: unknown): unknown {
|
|
74
|
+
if (u === undefined) return undefined
|
|
75
|
+
let serialized: string
|
|
76
|
+
try {
|
|
77
|
+
serialized = JSON.stringify(u)
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
if (serialized === undefined) return undefined
|
|
82
|
+
if (serialized.length <= MAX_UPSTREAM_ERROR_BYTES) return u
|
|
83
|
+
return serialized.slice(0, MAX_UPSTREAM_ERROR_BYTES)
|
|
84
|
+
}
|
|
85
|
+
|
|
45
86
|
// ---------------------------------------------------------------------------
|
|
46
87
|
// Async polling
|
|
47
88
|
// ---------------------------------------------------------------------------
|
|
@@ -49,7 +90,7 @@ function debug(msg: string): void {
|
|
|
49
90
|
async function executeAsync(
|
|
50
91
|
provider: ProviderEntry,
|
|
51
92
|
payload: { body?: unknown; queryParams?: Record<string, string> },
|
|
52
|
-
): Promise<{ ok: boolean; data: unknown }> {
|
|
93
|
+
): Promise<{ ok: boolean; data: unknown; headers: Record<string, string> }> {
|
|
53
94
|
const asyncCfg = provider.async!
|
|
54
95
|
const firstPollMs = parseInt(process.env.COLDIQ_FIRST_POLL_MS ?? '750', 10)
|
|
55
96
|
const maxPollErrors = parseInt(process.env.COLDIQ_MAX_POLL_ERRORS ?? '3', 10)
|
|
@@ -61,15 +102,18 @@ async function executeAsync(
|
|
|
61
102
|
debug(`${provider.id} async: create failed (${createRes.status})`)
|
|
62
103
|
return createRes
|
|
63
104
|
}
|
|
105
|
+
// Credit headers are emitted on the create response (where the reservation
|
|
106
|
+
// happens). Poll responses don't bill, so they won't carry them.
|
|
107
|
+
const createHeaders = createRes.headers
|
|
64
108
|
|
|
65
109
|
// Step 2: extract job ID
|
|
66
110
|
let jobId: string
|
|
67
111
|
try {
|
|
68
112
|
jobId = asyncCfg.extractId(createRes.data)
|
|
69
113
|
} catch {
|
|
70
|
-
return { ok: false, data: { error: 'Could not extract job ID from async response' } }
|
|
114
|
+
return { ok: false, data: { error: 'Could not extract job ID from async response' }, headers: createHeaders }
|
|
71
115
|
}
|
|
72
|
-
if (!jobId) return { ok: false, data: { error: 'Empty job ID from async response' } }
|
|
116
|
+
if (!jobId) return { ok: false, data: { error: 'Empty job ID from async response' }, headers: createHeaders }
|
|
73
117
|
debug(`${provider.id} async: job created (${jobId}), polling...`)
|
|
74
118
|
|
|
75
119
|
// Step 3: poll until complete or timeout.
|
|
@@ -93,7 +137,7 @@ async function executeAsync(
|
|
|
93
137
|
consecutivePollErrors++
|
|
94
138
|
if (consecutivePollErrors >= maxPollErrors) {
|
|
95
139
|
log(`${provider.id} async: poll failing (${consecutivePollErrors} consecutive errors), skipping`)
|
|
96
|
-
return { ok: false, data: { error: `Poll endpoint failed ${consecutivePollErrors} consecutive times` } }
|
|
140
|
+
return { ok: false, data: { error: `Poll endpoint failed ${consecutivePollErrors} consecutive times` }, headers: createHeaders }
|
|
97
141
|
}
|
|
98
142
|
} else {
|
|
99
143
|
consecutivePollErrors = 0
|
|
@@ -103,7 +147,7 @@ async function executeAsync(
|
|
|
103
147
|
|
|
104
148
|
if (asyncCfg.isComplete(pollRes.data)) {
|
|
105
149
|
debug(`${provider.id} async: complete`)
|
|
106
|
-
return pollRes
|
|
150
|
+
return { ...pollRes, headers: { ...createHeaders, ...pollRes.headers } }
|
|
107
151
|
}
|
|
108
152
|
}
|
|
109
153
|
|
|
@@ -117,13 +161,19 @@ async function executeAsync(
|
|
|
117
161
|
}
|
|
118
162
|
|
|
119
163
|
debug(`${provider.id} async: timed out after ${asyncCfg.timeoutMs / 1000}s`)
|
|
120
|
-
return { ok: false, data: { error: `Async operation timed out after ${asyncCfg.timeoutMs / 1000}s` } }
|
|
164
|
+
return { ok: false, data: { error: `Async operation timed out after ${asyncCfg.timeoutMs / 1000}s` }, headers: createHeaders }
|
|
121
165
|
}
|
|
122
166
|
|
|
123
167
|
function sleep(ms: number): Promise<void> {
|
|
124
168
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
125
169
|
}
|
|
126
170
|
|
|
171
|
+
function parseCreditHeader(raw: string | undefined): number | undefined {
|
|
172
|
+
if (raw === undefined) return undefined
|
|
173
|
+
const n = Number(raw)
|
|
174
|
+
return Number.isFinite(n) ? n : undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
127
177
|
// ---------------------------------------------------------------------------
|
|
128
178
|
// Execute a single provider
|
|
129
179
|
// ---------------------------------------------------------------------------
|
|
@@ -142,7 +192,7 @@ function syncProviderTimeoutMs(): number {
|
|
|
142
192
|
async function executeSingle(
|
|
143
193
|
provider: ProviderEntry,
|
|
144
194
|
input: Record<string, unknown>,
|
|
145
|
-
): Promise<{ ok: boolean; status: number; data: unknown; latencyMs: number }> {
|
|
195
|
+
): Promise<{ ok: boolean; status: number; data: unknown; latencyMs: number; headers: Record<string, string> }> {
|
|
146
196
|
const start = Date.now()
|
|
147
197
|
|
|
148
198
|
let payload: ReturnType<ProviderEntry['mapParams']>
|
|
@@ -151,12 +201,12 @@ async function executeSingle(
|
|
|
151
201
|
} catch (err) {
|
|
152
202
|
const msg = err instanceof Error ? err.message : String(err)
|
|
153
203
|
debug(`${provider.id}: mapParams threw — ${msg}`)
|
|
154
|
-
return { ok: false, status: 0, data: { error: `mapParams_threw: ${msg}` }, latencyMs: Date.now() - start }
|
|
204
|
+
return { ok: false, status: 0, data: { error: `mapParams_threw: ${msg}` }, latencyMs: Date.now() - start, headers: {} }
|
|
155
205
|
}
|
|
156
206
|
|
|
157
207
|
debug(`${provider.id}: payload_keys = ${Object.keys(payload?.body ?? {}).join(',')}`)
|
|
158
208
|
|
|
159
|
-
let result: { ok: boolean; status: number; data: unknown }
|
|
209
|
+
let result: { ok: boolean; status: number; data: unknown; headers: Record<string, string> }
|
|
160
210
|
|
|
161
211
|
if (provider.async) {
|
|
162
212
|
const asyncResult = await executeAsync(provider, payload)
|
|
@@ -164,13 +214,14 @@ async function executeSingle(
|
|
|
164
214
|
} else {
|
|
165
215
|
const cap = syncProviderTimeoutMs()
|
|
166
216
|
const call = callApi(provider.method, provider.endpoint, payload.body, payload.queryParams)
|
|
167
|
-
const timeout = new Promise<{ ok: boolean; status: number; data: unknown }>((resolve) =>
|
|
217
|
+
const timeout = new Promise<{ ok: boolean; status: number; data: unknown; headers: Record<string, string> }>((resolve) =>
|
|
168
218
|
setTimeout(
|
|
169
219
|
() =>
|
|
170
220
|
resolve({
|
|
171
221
|
ok: false,
|
|
172
222
|
status: 0,
|
|
173
223
|
data: { error: `Provider exceeded ${cap}ms sync cap` },
|
|
224
|
+
headers: {},
|
|
174
225
|
}),
|
|
175
226
|
cap,
|
|
176
227
|
),
|
|
@@ -215,7 +266,7 @@ export async function executeWithFallback(
|
|
|
215
266
|
allProviders = ordered
|
|
216
267
|
}
|
|
217
268
|
|
|
218
|
-
const errors: Array<{ id: string; status: number; error: string }> = []
|
|
269
|
+
const errors: Array<{ id: string; status: number; error: string; upstream_error?: unknown }> = []
|
|
219
270
|
let billingUrl: string | undefined
|
|
220
271
|
let zeroBalance = false
|
|
221
272
|
|
|
@@ -259,18 +310,29 @@ export async function executeWithFallback(
|
|
|
259
310
|
if (options?.matchedFrom && Object.keys(options.matchedFrom).length > 0) {
|
|
260
311
|
meta.matchedFrom = options.matchedFrom
|
|
261
312
|
}
|
|
313
|
+
const charged = parseCreditHeader(result.headers['x-coldiq-credits-charged'])
|
|
314
|
+
const remaining = parseCreditHeader(result.headers['x-coldiq-credits-remaining'])
|
|
315
|
+
if (charged !== undefined) meta.credits_charged = charged
|
|
316
|
+
if (remaining !== undefined) meta.credits_remaining = remaining
|
|
262
317
|
return { data: payload, _meta: meta }
|
|
263
318
|
}
|
|
264
319
|
|
|
265
320
|
// Record the failure
|
|
266
|
-
const
|
|
267
|
-
? (result.data as Record<string, unknown>)
|
|
321
|
+
const dataObj = result.data && typeof result.data === 'object'
|
|
322
|
+
? (result.data as Record<string, unknown>)
|
|
268
323
|
: undefined
|
|
324
|
+
const rawErr = dataObj && 'error' in dataObj ? dataObj.error : undefined
|
|
269
325
|
const errMsg = rawErr !== undefined
|
|
270
326
|
? (typeof rawErr === 'string' ? rawErr : JSON.stringify(rawErr))
|
|
271
327
|
: `Status ${result.status}, no results`
|
|
328
|
+
// Prefer the API's sanitized `details` passthrough; fall back to the full
|
|
329
|
+
// body so providers that haven't migrated yet still surface SOMETHING
|
|
330
|
+
// structured to the caller. `'details' in dataObj` (rather than `?? dataObj`)
|
|
331
|
+
// so an explicitly-emitted `details: null/false/0` is preserved verbatim
|
|
332
|
+
// instead of silently falling through to the full body.
|
|
333
|
+
const upstreamErr = dataObj && 'details' in dataObj ? dataObj.details : dataObj
|
|
272
334
|
log(`${capability} → ${provider.id} ✗ ${errMsg}`)
|
|
273
|
-
errors.push({ id: provider.id, status: result.status, error: errMsg })
|
|
335
|
+
errors.push({ id: provider.id, status: result.status, error: errMsg, upstream_error: upstreamErr })
|
|
274
336
|
|
|
275
337
|
// Capture the billing URL the API surfaces in 402 responses so the
|
|
276
338
|
// top-level error can include a clickable link for the user.
|
|
@@ -297,9 +359,19 @@ export async function executeWithFallback(
|
|
|
297
359
|
// When the user pinned specific providers, expose their real IDs in the error
|
|
298
360
|
// (they already know what they asked for). Otherwise anonymize to avoid leaking
|
|
299
361
|
// internal provider names on auto-route failures.
|
|
362
|
+
const buildEntry = (provider: string, e: { status: number; error: string; upstream_error?: unknown }) => {
|
|
363
|
+
const entry: ExecutionError['providers_tried'][number] = {
|
|
364
|
+
provider,
|
|
365
|
+
status: e.status,
|
|
366
|
+
error: e.error.slice(0, 200),
|
|
367
|
+
}
|
|
368
|
+
const capped = capUpstreamError(e.upstream_error)
|
|
369
|
+
if (capped !== undefined) entry.upstream_error = capped
|
|
370
|
+
return entry
|
|
371
|
+
}
|
|
300
372
|
const sanitized = isConstrained
|
|
301
|
-
? errors.map((e) => (
|
|
302
|
-
: errors.map((e, i) => (
|
|
373
|
+
? errors.map((e) => buildEntry(e.id, e))
|
|
374
|
+
: errors.map((e, i) => buildEntry(`provider_${i + 1}`, e))
|
|
303
375
|
|
|
304
376
|
const allOutOfCredits = zeroBalance
|
|
305
377
|
|| (errors.length > 0 && errors.every((e) => e.status === 402))
|
package/src/index.ts
CHANGED
|
@@ -123,6 +123,13 @@ import {
|
|
|
123
123
|
fetchPageContentHandler,
|
|
124
124
|
} from './tools/fetch-page-content.js'
|
|
125
125
|
|
|
126
|
+
import {
|
|
127
|
+
getCreditBalanceName,
|
|
128
|
+
getCreditBalanceDescription,
|
|
129
|
+
getCreditBalanceSchema,
|
|
130
|
+
getCreditBalanceHandler,
|
|
131
|
+
} from './tools/get-credit-balance.js'
|
|
132
|
+
|
|
126
133
|
// Initialize HTTP client (reads env vars)
|
|
127
134
|
initClient()
|
|
128
135
|
|
|
@@ -149,6 +156,7 @@ server.tool(searchRedditName, searchRedditDescription, searchRedditSchema, searc
|
|
|
149
156
|
server.tool(searchSeoName, searchSeoDescription, searchSeoSchema, searchSeoHandler)
|
|
150
157
|
server.tool(findSignalsName, findSignalsDescription, findSignalsSchema, findSignalsHandler)
|
|
151
158
|
server.tool(fetchPageContentName, fetchPageContentDescription, fetchPageContentSchema, fetchPageContentHandler)
|
|
159
|
+
server.tool(getCreditBalanceName, getCreditBalanceDescription, getCreditBalanceSchema, getCreditBalanceHandler)
|
|
152
160
|
|
|
153
161
|
// Connect via stdio transport
|
|
154
162
|
const transport = new StdioServerTransport()
|
package/src/registry.ts
CHANGED
|
@@ -76,6 +76,28 @@ function hasAnyKey(obj: unknown, keys: string[]): boolean {
|
|
|
76
76
|
})
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Prospeo only accepts headcount as a list of fixed buckets, not arbitrary
|
|
80
|
+
// min/max ranges. Map the user's numeric range to the overlapping buckets.
|
|
81
|
+
const PROSPEO_HEADCOUNT_BUCKETS: ReadonlyArray<{ label: string; low: number; high: number }> = [
|
|
82
|
+
{ label: '1-10', low: 1, high: 10 },
|
|
83
|
+
{ label: '11-20', low: 11, high: 20 },
|
|
84
|
+
{ label: '21-50', low: 21, high: 50 },
|
|
85
|
+
{ label: '51-100', low: 51, high: 100 },
|
|
86
|
+
{ label: '101-200', low: 101, high: 200 },
|
|
87
|
+
{ label: '201-500', low: 201, high: 500 },
|
|
88
|
+
{ label: '501-1000', low: 501, high: 1000 },
|
|
89
|
+
{ label: '1001-2000', low: 1001, high: 2000 },
|
|
90
|
+
{ label: '2001-5000', low: 2001, high: 5000 },
|
|
91
|
+
{ label: '5001-10000', low: 5001, high: 10000 },
|
|
92
|
+
{ label: '10000+', low: 10001, high: Number.POSITIVE_INFINITY },
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
export function prospeoHeadcountBuckets(min: number | undefined, max: number | undefined): string[] {
|
|
96
|
+
const lo = typeof min === 'number' ? min : 0
|
|
97
|
+
const hi = typeof max === 'number' ? max : Number.POSITIVE_INFINITY
|
|
98
|
+
return PROSPEO_HEADCOUNT_BUCKETS.filter((b) => b.low <= hi && b.high >= lo).map((b) => b.label)
|
|
99
|
+
}
|
|
100
|
+
|
|
79
101
|
function toLinkupFundingStage(stage: string): string | undefined {
|
|
80
102
|
const map: Record<string, string> = {
|
|
81
103
|
seed: 'Seed',
|
|
@@ -883,13 +905,13 @@ const searchCompaniesProviders: ProviderEntry[] = [
|
|
|
883
905
|
company_industry: { include: input.industries },
|
|
884
906
|
}),
|
|
885
907
|
...(isNonEmptyArray(input.countries) && {
|
|
886
|
-
|
|
908
|
+
company_location_search: { include: (input.countries as string[]).map(isoCountryToName) },
|
|
887
909
|
}),
|
|
888
910
|
...((input.min_employees !== undefined || input.max_employees !== undefined) && {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
911
|
+
company_headcount_range: prospeoHeadcountBuckets(
|
|
912
|
+
input.min_employees as number | undefined,
|
|
913
|
+
input.max_employees as number | undefined,
|
|
914
|
+
),
|
|
893
915
|
}),
|
|
894
916
|
},
|
|
895
917
|
},
|
|
@@ -985,6 +1007,12 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
985
1007
|
endpoint: '/leadsfactory/contact-finder/searches',
|
|
986
1008
|
method: 'POST',
|
|
987
1009
|
priority: 1,
|
|
1010
|
+
// LF is a company-scoped contact finder — upstream 500s with
|
|
1011
|
+
// "At least one of company_linkedin_urls or company_domains must be provided"
|
|
1012
|
+
// when neither is supplied. Skip for pure prospecting (titles/seniorities/keywords
|
|
1013
|
+
// only); apollo at priority 2 handles that path.
|
|
1014
|
+
isApplicable: (input) =>
|
|
1015
|
+
isNonEmptyArray(input.company_domains) || isNonEmptyArray(input.company_linkedin_urls),
|
|
988
1016
|
mapParams: (input) => {
|
|
989
1017
|
const rawTitles = (input.job_titles as string[] | undefined) ?? []
|
|
990
1018
|
// Step 1: deduplicate near-identical "of" variants (e.g. "VP Sales" ≡ "VP of Sales")
|
|
@@ -1075,6 +1103,10 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
1075
1103
|
endpoint: '/apollo/people/search',
|
|
1076
1104
|
method: 'POST',
|
|
1077
1105
|
priority: 2,
|
|
1106
|
+
// Apollo caps per_page at 100 and rejects larger values with a 400. The
|
|
1107
|
+
// find_people schema permits limit up to 500 (a waterfall-wide ceiling),
|
|
1108
|
+
// so clamp here to keep Apollo from breaking the request when the caller
|
|
1109
|
+
// pins use_providers=['apollo'] with a large limit.
|
|
1078
1110
|
mapParams: (input) => ({
|
|
1079
1111
|
body: {
|
|
1080
1112
|
person_titles: input.job_titles,
|
|
@@ -1082,7 +1114,7 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
1082
1114
|
person_seniorities: input.seniorities,
|
|
1083
1115
|
person_locations: input.locations,
|
|
1084
1116
|
q_keywords: (input.keywords as string[] | undefined)?.join(' ') || undefined,
|
|
1085
|
-
per_page: (input.limit as number) ?? 25,
|
|
1117
|
+
per_page: Math.min((input.limit as number) ?? 25, 100),
|
|
1086
1118
|
},
|
|
1087
1119
|
}),
|
|
1088
1120
|
hasResult: (data) => {
|
|
@@ -1127,12 +1159,27 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
1127
1159
|
endpoint: '/pdl/person/search',
|
|
1128
1160
|
method: 'POST',
|
|
1129
1161
|
priority: 3,
|
|
1162
|
+
// Refuse fully-unbounded queries: PDL accepts an empty `must` and would return its
|
|
1163
|
+
// entire index — turning the waterfall into a "random 25 humans" generator.
|
|
1164
|
+
isApplicable: (input) =>
|
|
1165
|
+
isNonEmptyArray(input.company_domains) ||
|
|
1166
|
+
isNonEmptyArray(input.company_linkedin_urls) ||
|
|
1167
|
+
isNonEmptyArray(input.job_titles) ||
|
|
1168
|
+
isNonEmptyArray(input.seniorities),
|
|
1130
1169
|
mapParams: (input) => {
|
|
1131
1170
|
const must: unknown[] = []
|
|
1132
1171
|
if (isNonEmptyArray(input.job_titles))
|
|
1133
1172
|
must.push({ terms: { job_title: input.job_titles } })
|
|
1134
1173
|
if (isNonEmptyArray(input.company_domains))
|
|
1135
1174
|
must.push({ terms: { job_company_website: input.company_domains } })
|
|
1175
|
+
if (isNonEmptyArray(input.company_linkedin_urls)) {
|
|
1176
|
+
// PDL indexes LinkedIn URLs in canonical short form (`linkedin.com/company/x`)
|
|
1177
|
+
// and `terms` is exact-match — full URLs from callers silently return 0 hits.
|
|
1178
|
+
const normalized = (input.company_linkedin_urls as string[]).map((u) =>
|
|
1179
|
+
u.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, ''),
|
|
1180
|
+
)
|
|
1181
|
+
must.push({ terms: { job_company_linkedin_url: normalized } })
|
|
1182
|
+
}
|
|
1136
1183
|
if (isNonEmptyArray(input.seniorities))
|
|
1137
1184
|
must.push({ terms: { job_title_levels: input.seniorities } })
|
|
1138
1185
|
if (isNonEmptyArray(input.locations))
|
|
@@ -1154,10 +1201,16 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
1154
1201
|
endpoint: '/companyenrich/people/search',
|
|
1155
1202
|
method: 'POST',
|
|
1156
1203
|
priority: 4,
|
|
1204
|
+
// Upstream `filters.companies` accepts domains only — no LinkedIn-URL field exists.
|
|
1205
|
+
// Skip when the caller supplied only LinkedIn URLs so we don't fall back to a
|
|
1206
|
+
// titles-only global search that masquerades as company-scoped.
|
|
1207
|
+
isApplicable: (input) =>
|
|
1208
|
+
isNonEmptyArray(input.company_domains) || !isNonEmptyArray(input.company_linkedin_urls),
|
|
1157
1209
|
mapParams: (input) => ({
|
|
1158
1210
|
body: {
|
|
1159
1211
|
filters: {
|
|
1160
1212
|
jobTitles: input.job_titles,
|
|
1213
|
+
companies: isNonEmptyArray(input.company_domains) ? input.company_domains : undefined,
|
|
1161
1214
|
countries: input.locations,
|
|
1162
1215
|
},
|
|
1163
1216
|
pageSize: (input.limit as number) ?? 25,
|
|
@@ -1236,7 +1289,7 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
1236
1289
|
body: {
|
|
1237
1290
|
filters: {
|
|
1238
1291
|
...(isNonEmptyArray(input.job_titles) && {
|
|
1239
|
-
|
|
1292
|
+
person_job_title: { include: input.job_titles },
|
|
1240
1293
|
}),
|
|
1241
1294
|
...(isNonEmptyArray(input.seniorities) && {
|
|
1242
1295
|
person_seniority: { include: input.seniorities },
|
|
@@ -1245,7 +1298,7 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
1245
1298
|
company: { websites: { include: input.company_domains } },
|
|
1246
1299
|
}),
|
|
1247
1300
|
...(isNonEmptyArray(input.locations) && {
|
|
1248
|
-
|
|
1301
|
+
person_location_search: { include: input.locations },
|
|
1249
1302
|
}),
|
|
1250
1303
|
},
|
|
1251
1304
|
},
|
|
@@ -1293,6 +1346,12 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
1293
1346
|
endpoint: '/fullenrich/people/search',
|
|
1294
1347
|
method: 'POST',
|
|
1295
1348
|
priority: 9,
|
|
1349
|
+
// Requires company_domains: the upstream rejects LinkedIn-URL variants as
|
|
1350
|
+
// `error.filters.empty` (despite the schema declaring `current_company_linkedin_urls`,
|
|
1351
|
+
// the live API doesn't honor it). Without any company filter, FullEnrich silently
|
|
1352
|
+
// degrades to a 50k-result titles-only search whose first 25 short-circuit the
|
|
1353
|
+
// waterfall via hasResult.
|
|
1354
|
+
isApplicable: (input) => isNonEmptyArray(input.company_domains),
|
|
1296
1355
|
mapParams: (input) => ({
|
|
1297
1356
|
body: {
|
|
1298
1357
|
current_company_domains: isNonEmptyArray(input.company_domains)
|