@coldiq/mcp 0.1.0
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 +8 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +47 -0
- package/dist/client.js.map +1 -0
- package/dist/executor.d.ts +21 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +130 -0
- package/dist/executor.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +49 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +3104 -0
- package/dist/registry.js.map +1 -0
- package/dist/tools/enrich-company.d.ts +22 -0
- package/dist/tools/enrich-company.d.ts.map +1 -0
- package/dist/tools/enrich-company.js +21 -0
- package/dist/tools/enrich-company.js.map +1 -0
- package/dist/tools/enrich-email.d.ts +24 -0
- package/dist/tools/enrich-email.d.ts.map +1 -0
- package/dist/tools/enrich-email.js +19 -0
- package/dist/tools/enrich-email.js.map +1 -0
- package/dist/tools/enrich-emails.d.ts +31 -0
- package/dist/tools/enrich-emails.d.ts.map +1 -0
- package/dist/tools/enrich-emails.js +146 -0
- package/dist/tools/enrich-emails.js.map +1 -0
- package/dist/tools/enrich-person.d.ts +26 -0
- package/dist/tools/enrich-person.d.ts.map +1 -0
- package/dist/tools/enrich-person.js +23 -0
- package/dist/tools/enrich-person.js.map +1 -0
- package/dist/tools/fetch-page-content.d.ts +22 -0
- package/dist/tools/fetch-page-content.d.ts.map +1 -0
- package/dist/tools/fetch-page-content.js +32 -0
- package/dist/tools/fetch-page-content.js.map +1 -0
- package/dist/tools/find-email.d.ts +24 -0
- package/dist/tools/find-email.d.ts.map +1 -0
- package/dist/tools/find-email.js +19 -0
- package/dist/tools/find-email.js.map +1 -0
- package/dist/tools/find-emails.d.ts +31 -0
- package/dist/tools/find-emails.d.ts.map +1 -0
- package/dist/tools/find-emails.js +146 -0
- package/dist/tools/find-emails.js.map +1 -0
- package/dist/tools/find-influencers.d.ts +29 -0
- package/dist/tools/find-influencers.d.ts.map +1 -0
- package/dist/tools/find-influencers.js +30 -0
- package/dist/tools/find-influencers.js.map +1 -0
- package/dist/tools/find-people.d.ts +26 -0
- package/dist/tools/find-people.d.ts.map +1 -0
- package/dist/tools/find-people.js +61 -0
- package/dist/tools/find-people.js.map +1 -0
- package/dist/tools/find-phone.d.ts +24 -0
- package/dist/tools/find-phone.d.ts.map +1 -0
- package/dist/tools/find-phone.js +48 -0
- package/dist/tools/find-phone.js.map +1 -0
- package/dist/tools/find-signals.d.ts +26 -0
- package/dist/tools/find-signals.d.ts.map +1 -0
- package/dist/tools/find-signals.js +82 -0
- package/dist/tools/find-signals.js.map +1 -0
- package/dist/tools/search-ads.d.ts +33 -0
- package/dist/tools/search-ads.d.ts.map +1 -0
- package/dist/tools/search-ads.js +33 -0
- package/dist/tools/search-ads.js.map +1 -0
- package/dist/tools/search-companies.d.ts +42 -0
- package/dist/tools/search-companies.d.ts.map +1 -0
- package/dist/tools/search-companies.js +37 -0
- package/dist/tools/search-companies.js.map +1 -0
- package/dist/tools/search-jobs.d.ts +51 -0
- package/dist/tools/search-jobs.d.ts.map +1 -0
- package/dist/tools/search-jobs.js +64 -0
- package/dist/tools/search-jobs.js.map +1 -0
- package/dist/tools/search-places.d.ts +47 -0
- package/dist/tools/search-places.d.ts.map +1 -0
- package/dist/tools/search-places.js +42 -0
- package/dist/tools/search-places.js.map +1 -0
- package/dist/tools/search-reddit.d.ts +27 -0
- package/dist/tools/search-reddit.d.ts.map +1 -0
- package/dist/tools/search-reddit.js +30 -0
- package/dist/tools/search-reddit.js.map +1 -0
- package/dist/tools/search-seo.d.ts +37 -0
- package/dist/tools/search-seo.d.ts.map +1 -0
- package/dist/tools/search-seo.js +49 -0
- package/dist/tools/search-seo.js.map +1 -0
- package/dist/tools/search-web.d.ts +23 -0
- package/dist/tools/search-web.d.ts.map +1 -0
- package/dist/tools/search-web.js +20 -0
- package/dist/tools/search-web.js.map +1 -0
- package/dist/tools/verify-email.d.ts +20 -0
- package/dist/tools/verify-email.d.ts.map +1 -0
- package/dist/tools/verify-email.js +15 -0
- package/dist/tools/verify-email.js.map +1 -0
- package/package.json +28 -0
- package/src/client.ts +60 -0
- package/src/executor.ts +182 -0
- package/src/index.ts +155 -0
- package/src/registry.ts +3159 -0
- package/src/tools/enrich-company.ts +25 -0
- package/src/tools/enrich-person.ts +27 -0
- package/src/tools/fetch-page-content.ts +36 -0
- package/src/tools/find-email.ts +23 -0
- package/src/tools/find-emails.ts +190 -0
- package/src/tools/find-influencers.ts +34 -0
- package/src/tools/find-people.ts +69 -0
- package/src/tools/find-phone.ts +53 -0
- package/src/tools/find-signals.ts +93 -0
- package/src/tools/search-ads.ts +44 -0
- package/src/tools/search-companies.ts +41 -0
- package/src/tools/search-jobs.ts +73 -0
- package/src/tools/search-places.ts +52 -0
- package/src/tools/search-reddit.ts +34 -0
- package/src/tools/search-seo.ts +59 -0
- package/src/tools/search-web.ts +24 -0
- package/src/tools/verify-email.ts +19 -0
- package/test-ads-live.ts +77 -0
- package/test-company-live.ts +91 -0
- package/test-email-live.ts +171 -0
- package/test-influencers-live.ts +66 -0
- package/test-jobs-live.ts +69 -0
- package/test-linkupapi-live.ts +137 -0
- package/test-phone-live.ts +41 -0
- package/test-places-live.ts +89 -0
- package/test-reddit-live.ts +66 -0
- package/test-search-live.ts +79 -0
- package/test-seo-live.ts +68 -0
- package/test-web-live.ts +67 -0
- package/tests/client.test.ts +90 -0
- package/tests/executor.test.ts +83 -0
- package/tests/gtm/01-icp-to-emails.test.ts +43 -0
- package/tests/gtm/02-icp-bulk-emails.test.ts +38 -0
- package/tests/gtm/03-icp-to-phones.test.ts +39 -0
- package/tests/gtm/04-funding-signal-outreach.test.ts +42 -0
- package/tests/gtm/05-hiring-signal-decisionmakers.test.ts +41 -0
- package/tests/gtm/06-intent-signal-outreach.test.ts +44 -0
- package/tests/gtm/07-places-to-content.test.ts +50 -0
- package/tests/gtm/08-domain-to-account.test.ts +44 -0
- package/tests/gtm/09-linkedin-to-everything.test.ts +41 -0
- package/tests/gtm/10-jobs-vs-signals-routing.test.ts +38 -0
- package/tests/gtm/11-find-vs-enrich-routing.test.ts +39 -0
- package/tests/gtm/12-bogus-domain-graceful.test.ts +42 -0
- package/tests/gtm/13-private-linkedin-graceful.test.ts +44 -0
- package/tests/gtm/14-empty-handoff.test.ts +43 -0
- package/tests/gtm/15-seo-reddit-research.test.ts +38 -0
- package/tests/gtm/README.md +59 -0
- package/tests/gtm/harness.ts +217 -0
- package/tests/gtm/tools-bridge.ts +232 -0
- package/tests/gtm-scenarios.md +32 -0
- package/tests/live/smoke-report.ts +255 -0
- package/tests/live/smoke.test.ts +134 -0
- package/tests/registry-enrich-person.test.ts +447 -0
- package/tests/registry-fetch-page-content.test.ts +90 -0
- package/tests/registry-find-people.test.ts +467 -0
- package/tests/registry-find-signals.test.ts +470 -0
- package/tests/registry-linkupapi.test.ts +331 -0
- package/tests/registry-search-companies.test.ts +188 -0
- package/tests/registry-search-jobs.test.ts +116 -0
- package/tests/registry.test.ts +2210 -0
- package/tests/tools/enrich-company.test.ts +92 -0
- package/tests/tools/enrich-email.test.ts +94 -0
- package/tests/tools/enrich-emails.test.ts +271 -0
- package/tests/tools/enrich-person.test.ts +140 -0
- package/tests/tools/fetch-page-content.test.ts +108 -0
- package/tests/tools/find-influencers.test.ts +91 -0
- package/tests/tools/find-people.test.ts +344 -0
- package/tests/tools/find-phone.test.ts +100 -0
- package/tests/tools/find-signals.test.ts +110 -0
- package/tests/tools/search-ads.test.ts +182 -0
- package/tests/tools/search-companies.test.ts +58 -0
- package/tests/tools/search-jobs.test.ts +210 -0
- package/tests/tools/search-places.test.ts +114 -0
- package/tests/tools/search-reddit.test.ts +125 -0
- package/tests/tools/search-seo.test.ts +183 -0
- package/tests/tools/search-web.test.ts +79 -0
- package/tests/tools/verify-email.test.ts +68 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +7 -0
package/src/registry.ts
ADDED
|
@@ -0,0 +1,3159 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Provider Registry — maps capabilities to ordered provider lists
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export interface AsyncConfig {
|
|
6
|
+
/** Build the poll endpoint path from the ID returned by the create call */
|
|
7
|
+
pollEndpoint: (id: string) => string
|
|
8
|
+
/** Milliseconds between poll attempts */
|
|
9
|
+
pollIntervalMs: number
|
|
10
|
+
/** Max milliseconds before giving up */
|
|
11
|
+
timeoutMs: number
|
|
12
|
+
/** Check if the async job is done */
|
|
13
|
+
isComplete: (data: unknown) => boolean
|
|
14
|
+
/** Extract the job ID from the create response */
|
|
15
|
+
extractId: (response: unknown) => string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProviderEntry {
|
|
19
|
+
id: string
|
|
20
|
+
endpoint: string
|
|
21
|
+
method: 'GET' | 'POST'
|
|
22
|
+
priority: number
|
|
23
|
+
mapParams: (input: Record<string, unknown>) => { body?: unknown; queryParams?: Record<string, string> }
|
|
24
|
+
hasResult: (data: unknown) => boolean
|
|
25
|
+
async?: AsyncConfig
|
|
26
|
+
/**
|
|
27
|
+
* Optional gate that returns false when the provider's required input is missing.
|
|
28
|
+
* The executor skips applicabilityFalsy providers entirely (no upstream call, no credits).
|
|
29
|
+
* Use for providers that need specific inputs (e.g. LinkedIn URL, Sales Nav URL, employee range).
|
|
30
|
+
*/
|
|
31
|
+
isApplicable?: (input: Record<string, unknown>) => boolean
|
|
32
|
+
/**
|
|
33
|
+
* Optional post-processor applied to the raw API response before hasResult.
|
|
34
|
+
* Use to strip irrelevant keys, drop records that fail strict filter checks, or
|
|
35
|
+
* translate the response shape. If the filtered result fails hasResult, the executor
|
|
36
|
+
* falls through to the next provider as if the call had returned no results.
|
|
37
|
+
*/
|
|
38
|
+
postFilter?: (data: unknown, input: Record<string, unknown>) => unknown
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type Capability =
|
|
42
|
+
| 'search_companies'
|
|
43
|
+
| 'find_people'
|
|
44
|
+
| 'find_email'
|
|
45
|
+
| 'verify_email'
|
|
46
|
+
| 'find_phone'
|
|
47
|
+
| 'enrich_company'
|
|
48
|
+
| 'enrich_person'
|
|
49
|
+
| 'search_web'
|
|
50
|
+
| 'search_jobs'
|
|
51
|
+
| 'search_ads'
|
|
52
|
+
| 'search_places'
|
|
53
|
+
| 'find_influencers'
|
|
54
|
+
| 'search_reddit'
|
|
55
|
+
| 'search_seo'
|
|
56
|
+
| 'find_signals'
|
|
57
|
+
| 'fetch_page_content'
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Helpers
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function isNonEmptyArray(v: unknown): boolean {
|
|
64
|
+
return Array.isArray(v) && v.length > 0
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasAnyKey(obj: unknown, keys: string[]): boolean {
|
|
68
|
+
if (!obj || typeof obj !== 'object') return false
|
|
69
|
+
return keys.some((k) => {
|
|
70
|
+
const val = (obj as Record<string, unknown>)[k]
|
|
71
|
+
return val !== undefined && val !== null && val !== ''
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toLinkupFundingStage(stage: string): string | undefined {
|
|
76
|
+
const map: Record<string, string> = {
|
|
77
|
+
seed: 'Seed',
|
|
78
|
+
'series a': 'Series A',
|
|
79
|
+
series_a: 'Series A',
|
|
80
|
+
'series b': 'Series B',
|
|
81
|
+
series_b: 'Series B',
|
|
82
|
+
'series c': 'Series C',
|
|
83
|
+
series_c: 'Series C',
|
|
84
|
+
'series d': 'Series D+',
|
|
85
|
+
series_d: 'Series D+',
|
|
86
|
+
'series d+': 'Series D+',
|
|
87
|
+
'series_d+': 'Series D+',
|
|
88
|
+
}
|
|
89
|
+
return map[stage.toLowerCase()]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// ISO 3166-1 alpha-2 → English country name
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
const _ALPHA2_RE = /^[A-Z]{2}$/i
|
|
97
|
+
const _displayNames = new Intl.DisplayNames(['en'], { type: 'region' })
|
|
98
|
+
|
|
99
|
+
export function isoCountryToName(code: string): string {
|
|
100
|
+
if (!_ALPHA2_RE.test(code)) return code
|
|
101
|
+
try {
|
|
102
|
+
return _displayNames.of(code.toUpperCase()) ?? code
|
|
103
|
+
} catch {
|
|
104
|
+
return code
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Strict post-filter helpers
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
type StrictFieldMap = {
|
|
113
|
+
foundedYearField: string
|
|
114
|
+
employeeCountField: string | string[]
|
|
115
|
+
countryField: string | string[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readNested(obj: Record<string, unknown>, path: string): unknown {
|
|
119
|
+
return path.split('.').reduce<unknown>(
|
|
120
|
+
(acc, key) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[key] : undefined),
|
|
121
|
+
obj,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function applyStrictFilters(
|
|
126
|
+
orgs: Array<Record<string, unknown>>,
|
|
127
|
+
input: Record<string, unknown>,
|
|
128
|
+
map: StrictFieldMap,
|
|
129
|
+
): Array<Record<string, unknown>> {
|
|
130
|
+
const minYear = input.min_founded_year as number | undefined
|
|
131
|
+
const maxYear = input.max_founded_year as number | undefined
|
|
132
|
+
const minEmp = input.min_employees as number | undefined
|
|
133
|
+
const maxEmp = input.max_employees as number | undefined
|
|
134
|
+
const wantCountries = ((input.countries as string[] | undefined) ?? []).map((c) =>
|
|
135
|
+
isoCountryToName(c).toLowerCase(),
|
|
136
|
+
)
|
|
137
|
+
const empFields = Array.isArray(map.employeeCountField) ? map.employeeCountField : [map.employeeCountField]
|
|
138
|
+
const countryFields = Array.isArray(map.countryField) ? map.countryField : [map.countryField]
|
|
139
|
+
|
|
140
|
+
return orgs.filter((o) => {
|
|
141
|
+
if (minYear !== undefined || maxYear !== undefined) {
|
|
142
|
+
const y = readNested(o, map.foundedYearField)
|
|
143
|
+
if (typeof y !== 'number') return false
|
|
144
|
+
if (minYear !== undefined && y < minYear) return false
|
|
145
|
+
if (maxYear !== undefined && y > maxYear) return false
|
|
146
|
+
}
|
|
147
|
+
if (minEmp !== undefined || maxEmp !== undefined) {
|
|
148
|
+
let e: number | undefined
|
|
149
|
+
for (const f of empFields) {
|
|
150
|
+
const v = readNested(o, f)
|
|
151
|
+
if (typeof v === 'number') { e = v; break }
|
|
152
|
+
}
|
|
153
|
+
if (e === undefined) return false
|
|
154
|
+
if (minEmp !== undefined && e < minEmp) return false
|
|
155
|
+
if (maxEmp !== undefined && e > maxEmp) return false
|
|
156
|
+
}
|
|
157
|
+
if (wantCountries.length > 0) {
|
|
158
|
+
let c: string | undefined
|
|
159
|
+
for (const f of countryFields) {
|
|
160
|
+
const v = readNested(o, f)
|
|
161
|
+
if (typeof v === 'string' && v.length > 0) { c = v; break }
|
|
162
|
+
}
|
|
163
|
+
if (!c) return false
|
|
164
|
+
if (!wantCountries.includes(isoCountryToName(c).toLowerCase())) return false
|
|
165
|
+
}
|
|
166
|
+
return true
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// search_companies
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
// LimaData company_headcount uses letter buckets: A=1-10, B=11-50, C=51-200,
|
|
175
|
+
// D=201-500, E=501-1000, F=1001-5000, G=5001-10000, H=10001+
|
|
176
|
+
function mapEmployeesToLimaDataHeadcount(min?: number, max?: number): string[] {
|
|
177
|
+
const buckets: Array<{ code: string; min: number; max: number }> = [
|
|
178
|
+
{ code: 'A', min: 1, max: 10 },
|
|
179
|
+
{ code: 'B', min: 11, max: 50 },
|
|
180
|
+
{ code: 'C', min: 51, max: 200 },
|
|
181
|
+
{ code: 'D', min: 201, max: 500 },
|
|
182
|
+
{ code: 'E', min: 501, max: 1000 },
|
|
183
|
+
{ code: 'F', min: 1001, max: 5000 },
|
|
184
|
+
{ code: 'G', min: 5001, max: 10000 },
|
|
185
|
+
{ code: 'H', min: 10001, max: Number.MAX_SAFE_INTEGER },
|
|
186
|
+
]
|
|
187
|
+
const lo = min ?? 0
|
|
188
|
+
const hi = max ?? Number.MAX_SAFE_INTEGER
|
|
189
|
+
return buckets.filter((b) => b.max >= lo && b.min <= hi).map((b) => b.code)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const searchCompaniesProviders: ProviderEntry[] = [
|
|
193
|
+
{
|
|
194
|
+
id: 'companyenrich',
|
|
195
|
+
endpoint: '/companyenrich/companies/search',
|
|
196
|
+
method: 'POST',
|
|
197
|
+
priority: 1,
|
|
198
|
+
mapParams: (input) => {
|
|
199
|
+
// CompanyEnrich industries require exact taxonomy matches — free-text values like
|
|
200
|
+
// "SaaS" produce empty results. Fold industries into keywords for flexible matching.
|
|
201
|
+
const keywords = [
|
|
202
|
+
...((input.keywords as string[] | undefined) ?? []),
|
|
203
|
+
...((input.industries as string[] | undefined) ?? []),
|
|
204
|
+
]
|
|
205
|
+
const excludeObj: Record<string, unknown> = {}
|
|
206
|
+
if (isNonEmptyArray(input.exclude_domains)) excludeObj.domains = input.exclude_domains
|
|
207
|
+
if (isNonEmptyArray(input.exclude_industries)) excludeObj.industries = input.exclude_industries
|
|
208
|
+
if (isNonEmptyArray(input.exclude_countries)) excludeObj.countries = input.exclude_countries
|
|
209
|
+
return {
|
|
210
|
+
body: {
|
|
211
|
+
countries: input.countries,
|
|
212
|
+
keywords: keywords.length > 0 ? keywords : undefined,
|
|
213
|
+
technologies: isNonEmptyArray(input.technologies) ? input.technologies : undefined,
|
|
214
|
+
employees:
|
|
215
|
+
input.min_employees || input.max_employees
|
|
216
|
+
? [{ from: input.min_employees, to: input.max_employees }]
|
|
217
|
+
: undefined,
|
|
218
|
+
foundedYear:
|
|
219
|
+
input.min_founded_year || input.max_founded_year
|
|
220
|
+
? { from: input.min_founded_year, to: input.max_founded_year }
|
|
221
|
+
: undefined,
|
|
222
|
+
fundingAmount:
|
|
223
|
+
input.min_funding_amount !== undefined || input.max_funding_amount !== undefined
|
|
224
|
+
? { from: input.min_funding_amount, to: input.max_funding_amount }
|
|
225
|
+
: undefined,
|
|
226
|
+
fundingYear:
|
|
227
|
+
input.min_funding_year !== undefined || input.max_funding_year !== undefined
|
|
228
|
+
? { from: input.min_funding_year, to: input.max_funding_year }
|
|
229
|
+
: undefined,
|
|
230
|
+
revenue:
|
|
231
|
+
input.min_revenue !== undefined || input.max_revenue !== undefined
|
|
232
|
+
? [{ from: input.min_revenue, to: input.max_revenue }]
|
|
233
|
+
: undefined,
|
|
234
|
+
workforceGrowth:
|
|
235
|
+
typeof input.min_workforce_growth_pct === 'number'
|
|
236
|
+
? { from: input.min_workforce_growth_pct }
|
|
237
|
+
: undefined,
|
|
238
|
+
exclude: Object.keys(excludeObj).length > 0 ? excludeObj : undefined,
|
|
239
|
+
pageSize: (input.limit as number) ?? 25,
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
hasResult: (data) => {
|
|
244
|
+
const d = data as Record<string, unknown>
|
|
245
|
+
return isNonEmptyArray(d.data) || isNonEmptyArray(d.results) || isNonEmptyArray(d.companies)
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: 'apollo',
|
|
250
|
+
endpoint: '/apollo/organizations/search',
|
|
251
|
+
method: 'POST',
|
|
252
|
+
priority: 8,
|
|
253
|
+
isApplicable: (input) => {
|
|
254
|
+
// Apollo has no founded-year, technologies, funding, revenue, exclusion, or hiring filters — skip when set.
|
|
255
|
+
if (typeof input.min_founded_year === 'number' || typeof input.max_founded_year === 'number') return false
|
|
256
|
+
if (isNonEmptyArray(input.technologies)) return false
|
|
257
|
+
if (isNonEmptyArray(input.funding_stages)) return false
|
|
258
|
+
if (typeof input.min_funding_amount === 'number' || typeof input.max_funding_amount === 'number') return false
|
|
259
|
+
if (typeof input.min_revenue === 'number' || typeof input.max_revenue === 'number') return false
|
|
260
|
+
if (isNonEmptyArray(input.exclude_domains) || isNonEmptyArray(input.exclude_industries) || isNonEmptyArray(input.exclude_countries)) return false
|
|
261
|
+
if (typeof input.min_workforce_growth_pct === 'number') return false
|
|
262
|
+
if (input.is_hiring === true) return false
|
|
263
|
+
return true
|
|
264
|
+
},
|
|
265
|
+
mapParams: (input) => {
|
|
266
|
+
// Apollo has no dedicated industries filter — fold them into keyword tags alongside keywords.
|
|
267
|
+
const keywordTags = [
|
|
268
|
+
...((input.keywords as string[] | undefined) ?? []),
|
|
269
|
+
...((input.industries as string[] | undefined) ?? []),
|
|
270
|
+
]
|
|
271
|
+
// organization_locations accepts free-text English strings ("Paris, France", "France").
|
|
272
|
+
// Translate ISO alpha-2 country codes to English names before forwarding.
|
|
273
|
+
const countryNames = ((input.countries as string[] | undefined) ?? []).map(isoCountryToName)
|
|
274
|
+
const orgLocations = [
|
|
275
|
+
...((input.locations as string[] | undefined) ?? []),
|
|
276
|
+
...countryNames,
|
|
277
|
+
]
|
|
278
|
+
return {
|
|
279
|
+
body: {
|
|
280
|
+
q_organization_keyword_tags: keywordTags.length > 0 ? keywordTags : undefined,
|
|
281
|
+
organization_locations: orgLocations.length > 0 ? orgLocations : undefined,
|
|
282
|
+
organization_num_employees_ranges:
|
|
283
|
+
input.min_employees || input.max_employees
|
|
284
|
+
? [`${(input.min_employees as number) ?? 0},${(input.max_employees as number) ?? 1000000}`]
|
|
285
|
+
: undefined,
|
|
286
|
+
per_page: (input.limit as number) ?? 25,
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
hasResult: (data) => {
|
|
291
|
+
const d = data as Record<string, unknown>
|
|
292
|
+
return isNonEmptyArray(d.organizations)
|
|
293
|
+
},
|
|
294
|
+
postFilter: (data, input) => {
|
|
295
|
+
const d = { ...(data as Record<string, unknown>) }
|
|
296
|
+
// CRM accounts are workspace-scoped records, not Apollo's global company DB — strip them.
|
|
297
|
+
delete d.accounts
|
|
298
|
+
const minEmp = input.min_employees as number | undefined
|
|
299
|
+
const maxEmp = input.max_employees as number | undefined
|
|
300
|
+
const wantCountries = ((input.countries as string[] | undefined) ?? []).map((c) =>
|
|
301
|
+
isoCountryToName(c).toLowerCase(),
|
|
302
|
+
)
|
|
303
|
+
const orgs = (d.organizations as Array<Record<string, unknown>> | undefined) ?? []
|
|
304
|
+
// Lenient check: only drop when the field is PRESENT and wrong (not strict-on-missing).
|
|
305
|
+
// Apollo's geo and headcount filters are fuzzy, so records missing a field still benefit
|
|
306
|
+
// from the doubt. Year filter is handled via isApplicable (Apollo is skipped entirely).
|
|
307
|
+
d.organizations = orgs.filter((o) => {
|
|
308
|
+
const emp = o.estimated_num_employees
|
|
309
|
+
if (typeof emp === 'number') {
|
|
310
|
+
if (minEmp !== undefined && emp < minEmp) return false
|
|
311
|
+
if (maxEmp !== undefined && emp > maxEmp) return false
|
|
312
|
+
}
|
|
313
|
+
if (wantCountries.length > 0) {
|
|
314
|
+
const c = o.country
|
|
315
|
+
if (typeof c === 'string' && c.length > 0) {
|
|
316
|
+
if (!wantCountries.includes(isoCountryToName(c).toLowerCase())) return false
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return true
|
|
320
|
+
})
|
|
321
|
+
return d
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
id: 'fullenrich',
|
|
326
|
+
endpoint: '/fullenrich/company/search',
|
|
327
|
+
method: 'POST',
|
|
328
|
+
priority: 2,
|
|
329
|
+
mapParams: (input) => {
|
|
330
|
+
// FullEnrich wraps each filter value in { value: x } and uses { min, max } for ranges.
|
|
331
|
+
const wrap = (vals?: string[]) => (Array.isArray(vals) && vals.length > 0 ? vals.map((v) => ({ value: v })) : undefined)
|
|
332
|
+
const range = (lo?: number, hi?: number) => {
|
|
333
|
+
if (lo === undefined && hi === undefined) return undefined
|
|
334
|
+
return [{
|
|
335
|
+
...(typeof lo === 'number' ? { min: lo } : {}),
|
|
336
|
+
...(typeof hi === 'number' ? { max: hi } : {}),
|
|
337
|
+
}]
|
|
338
|
+
}
|
|
339
|
+
// FullEnrich headquarters_locations accepts free-text English names — translate ISO codes.
|
|
340
|
+
const locationVals = [
|
|
341
|
+
...((input.locations as string[] | undefined) ?? []),
|
|
342
|
+
...((input.countries as string[] | undefined) ?? []).map(isoCountryToName),
|
|
343
|
+
]
|
|
344
|
+
return {
|
|
345
|
+
body: {
|
|
346
|
+
keywords: wrap(input.keywords as string[] | undefined),
|
|
347
|
+
industries: wrap(input.industries as string[] | undefined),
|
|
348
|
+
headquarters_locations: wrap(locationVals),
|
|
349
|
+
headcounts: range(input.min_employees as number | undefined, input.max_employees as number | undefined),
|
|
350
|
+
founded_years: range(input.min_founded_year as number | undefined, input.max_founded_year as number | undefined),
|
|
351
|
+
limit: (input.limit as number) ?? 25,
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
hasResult: (data) => {
|
|
356
|
+
const d = data as Record<string, unknown>
|
|
357
|
+
return isNonEmptyArray(d.companies) || isNonEmptyArray(d.data) || isNonEmptyArray(d.results)
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
id: 'pdl',
|
|
362
|
+
endpoint: '/pdl/company/search',
|
|
363
|
+
method: 'POST',
|
|
364
|
+
priority: 3,
|
|
365
|
+
mapParams: (input) => {
|
|
366
|
+
const must: unknown[] = []
|
|
367
|
+
if (isNonEmptyArray(input.industries))
|
|
368
|
+
must.push({ terms: { industry: input.industries } })
|
|
369
|
+
if (isNonEmptyArray(input.countries))
|
|
370
|
+
must.push({ terms: { 'location.country': input.countries } })
|
|
371
|
+
if (input.min_employees || input.max_employees)
|
|
372
|
+
must.push({
|
|
373
|
+
range: {
|
|
374
|
+
employee_count: {
|
|
375
|
+
...(input.min_employees ? { gte: input.min_employees } : {}),
|
|
376
|
+
...(input.max_employees ? { lte: input.max_employees } : {}),
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
if (input.min_founded_year || input.max_founded_year)
|
|
381
|
+
must.push({
|
|
382
|
+
range: {
|
|
383
|
+
founded: {
|
|
384
|
+
...(input.min_founded_year ? { gte: input.min_founded_year } : {}),
|
|
385
|
+
...(input.max_founded_year ? { lte: input.max_founded_year } : {}),
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
})
|
|
389
|
+
return {
|
|
390
|
+
body: {
|
|
391
|
+
query: must.length > 0 ? { bool: { must } } : undefined,
|
|
392
|
+
size: (input.limit as number) ?? 25,
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
hasResult: (data) => {
|
|
397
|
+
const d = data as Record<string, unknown>
|
|
398
|
+
return isNonEmptyArray(d.data) || (typeof d.total === 'number' && d.total > 0)
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
id: 'signalbase',
|
|
403
|
+
endpoint: '/signalbase/companies',
|
|
404
|
+
method: 'GET',
|
|
405
|
+
priority: 6,
|
|
406
|
+
isApplicable: (input) => {
|
|
407
|
+
// Skip for filter types SignalBase cannot apply — let specialized providers (TheirStack, Wiza,
|
|
408
|
+
// LimaData) handle these, and fall further down the waterfall when they fail.
|
|
409
|
+
if (isNonEmptyArray(input.technologies)) return false
|
|
410
|
+
if (isNonEmptyArray(input.funding_stages)) return false
|
|
411
|
+
if (typeof input.min_funding_amount === 'number' || typeof input.max_funding_amount === 'number') return false
|
|
412
|
+
if (typeof input.min_revenue === 'number' || typeof input.max_revenue === 'number') return false
|
|
413
|
+
if (input.is_hiring === true) return false
|
|
414
|
+
return true
|
|
415
|
+
},
|
|
416
|
+
mapParams: (input) => {
|
|
417
|
+
// SignalBase has separate `search` (free-text) and `industry` (exact-match) params —
|
|
418
|
+
// use each for the right input type to maximise precision.
|
|
419
|
+
const keywords = (input.keywords as string[] | undefined) ?? []
|
|
420
|
+
const industries = (input.industries as string[] | undefined) ?? []
|
|
421
|
+
const queryParams: Record<string, string> = {}
|
|
422
|
+
if (keywords.length > 0) queryParams.search = keywords.join(' ')
|
|
423
|
+
if (industries.length > 0) queryParams.industry = industries.join(',')
|
|
424
|
+
if (isNonEmptyArray(input.countries)) queryParams.countries = (input.countries as string[]).join(',')
|
|
425
|
+
if (typeof input.min_employees === 'number') queryParams.employee_count_min = String(input.min_employees)
|
|
426
|
+
if (typeof input.max_employees === 'number') queryParams.employee_count_max = String(input.max_employees)
|
|
427
|
+
if (typeof input.min_founded_year === 'number') queryParams.founded_year_min = String(input.min_founded_year)
|
|
428
|
+
if (typeof input.max_founded_year === 'number') queryParams.founded_year_max = String(input.max_founded_year)
|
|
429
|
+
queryParams.limit = String((input.limit as number) ?? 25)
|
|
430
|
+
return { queryParams }
|
|
431
|
+
},
|
|
432
|
+
hasResult: (data) => {
|
|
433
|
+
const d = data as Record<string, unknown>
|
|
434
|
+
return isNonEmptyArray(d.data) || isNonEmptyArray(d.companies) || isNonEmptyArray(d.results)
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
id: 'blitzapi',
|
|
439
|
+
endpoint: '/blitzapi/search/companies',
|
|
440
|
+
method: 'POST',
|
|
441
|
+
priority: 7,
|
|
442
|
+
mapParams: (input) => {
|
|
443
|
+
// BlitzAPI's industry filter expects LinkedIn's exact taxonomy names — free-text
|
|
444
|
+
// strings rarely match. We rely on `keywords` (forgiving free-text) instead and
|
|
445
|
+
// fold our `industries` input into keywords as well.
|
|
446
|
+
const keywords = [
|
|
447
|
+
...((input.keywords as string[] | undefined) ?? []),
|
|
448
|
+
...((input.industries as string[] | undefined) ?? []),
|
|
449
|
+
]
|
|
450
|
+
const countries = (input.countries as string[] | undefined) ?? []
|
|
451
|
+
const cityIncludes = (input.locations as string[] | undefined) ?? []
|
|
452
|
+
const company: Record<string, unknown> = {}
|
|
453
|
+
if (keywords.length > 0) company.keywords = { include: keywords }
|
|
454
|
+
if (typeof input.min_employees === 'number' || typeof input.max_employees === 'number') {
|
|
455
|
+
company.employee_count = {
|
|
456
|
+
...(typeof input.min_employees === 'number' ? { min: input.min_employees } : {}),
|
|
457
|
+
...(typeof input.max_employees === 'number' ? { max: input.max_employees } : {}),
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (typeof input.min_founded_year === 'number' || typeof input.max_founded_year === 'number') {
|
|
461
|
+
company.founded_year = {
|
|
462
|
+
...(typeof input.min_founded_year === 'number' ? { min: input.min_founded_year } : {}),
|
|
463
|
+
...(typeof input.max_founded_year === 'number' ? { max: input.max_founded_year } : {}),
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (countries.length > 0 || cityIncludes.length > 0) {
|
|
467
|
+
const hq: Record<string, unknown> = {}
|
|
468
|
+
if (countries.length > 0) hq.country_code = countries
|
|
469
|
+
if (cityIncludes.length > 0) hq.city = { include: cityIncludes }
|
|
470
|
+
company.hq = hq
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
body: {
|
|
474
|
+
company,
|
|
475
|
+
max_results: Math.min((input.limit as number) ?? 10, 50),
|
|
476
|
+
},
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
hasResult: (data) => {
|
|
480
|
+
const d = data as Record<string, unknown>
|
|
481
|
+
return isNonEmptyArray(d.companies) || isNonEmptyArray(d.data) || isNonEmptyArray(d.results)
|
|
482
|
+
},
|
|
483
|
+
isApplicable: (input) => {
|
|
484
|
+
// Skip when advanced filters are set that BlitzAPI cannot apply — specialized providers
|
|
485
|
+
// (TheirStack, Wiza, LimaData) handle these and must run without BlitzAPI intercepting first.
|
|
486
|
+
if (isNonEmptyArray(input.technologies)) return false
|
|
487
|
+
if (isNonEmptyArray(input.funding_stages)) return false
|
|
488
|
+
if (typeof input.min_funding_amount === 'number' || typeof input.max_funding_amount === 'number') return false
|
|
489
|
+
if (typeof input.min_revenue === 'number' || typeof input.max_revenue === 'number') return false
|
|
490
|
+
if (isNonEmptyArray(input.exclude_industries) || isNonEmptyArray(input.exclude_countries)) return false
|
|
491
|
+
if (input.is_hiring === true) return false
|
|
492
|
+
// Skip when no narrowing filter at all — an unbounded BlitzAPI search is wasteful.
|
|
493
|
+
const hasKeywords = isNonEmptyArray(input.keywords) || isNonEmptyArray(input.industries)
|
|
494
|
+
const hasGeo = isNonEmptyArray(input.countries) || isNonEmptyArray(input.locations)
|
|
495
|
+
const hasSize = typeof input.min_employees === 'number' || typeof input.max_employees === 'number'
|
|
496
|
+
return hasKeywords || hasGeo || hasSize
|
|
497
|
+
},
|
|
498
|
+
postFilter: (data, input) => {
|
|
499
|
+
const d = { ...(data as Record<string, unknown>) }
|
|
500
|
+
const minEmp = input.min_employees as number | undefined
|
|
501
|
+
const maxEmp = input.max_employees as number | undefined
|
|
502
|
+
if (minEmp === undefined && maxEmp === undefined) return d
|
|
503
|
+
for (const key of ['results', 'data', 'companies']) {
|
|
504
|
+
const arr = d[key] as Array<Record<string, unknown>> | undefined
|
|
505
|
+
if (!Array.isArray(arr)) continue
|
|
506
|
+
d[key] = arr.filter((o) => {
|
|
507
|
+
const emp = o.employees_on_linkedin as number | undefined
|
|
508
|
+
if (typeof emp !== 'number') return true
|
|
509
|
+
if (minEmp !== undefined && emp < minEmp) return false
|
|
510
|
+
if (maxEmp !== undefined && emp > maxEmp) return false
|
|
511
|
+
return true
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
return d
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
id: 'limadata',
|
|
519
|
+
endpoint: '/coldiq/search/companies',
|
|
520
|
+
method: 'POST',
|
|
521
|
+
priority: 9,
|
|
522
|
+
mapParams: (input) => {
|
|
523
|
+
const keywords = [
|
|
524
|
+
...((input.keywords as string[] | undefined) ?? []),
|
|
525
|
+
...((input.industries as string[] | undefined) ?? []),
|
|
526
|
+
]
|
|
527
|
+
const sizeBuckets = mapEmployeesToLimaDataHeadcount(
|
|
528
|
+
input.min_employees as number | undefined,
|
|
529
|
+
input.max_employees as number | undefined,
|
|
530
|
+
)
|
|
531
|
+
return {
|
|
532
|
+
body: {
|
|
533
|
+
query: keywords.join(' ') || 'company',
|
|
534
|
+
...(sizeBuckets.length > 0 && sizeBuckets.length < 8 ? { company_size: sizeBuckets.join(',') } : {}),
|
|
535
|
+
...(input.is_hiring === true ? { has_jobs: true } : {}),
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
hasResult: (data) => {
|
|
540
|
+
const d = data as Record<string, unknown>
|
|
541
|
+
return isNonEmptyArray(d.data) || isNonEmptyArray(d.companies) || isNonEmptyArray(d.results)
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
id: 'predictleads',
|
|
546
|
+
endpoint: '/predictleads/discover/companies',
|
|
547
|
+
method: 'GET',
|
|
548
|
+
priority: 10,
|
|
549
|
+
mapParams: (input) => {
|
|
550
|
+
// PredictLeads requires BOTH `location` AND `sizes` (Zod-required) per its schema.
|
|
551
|
+
// isApplicable below ensures we only fire when both are derivable.
|
|
552
|
+
const sizeRanges: string[] = []
|
|
553
|
+
const min = input.min_employees as number | undefined
|
|
554
|
+
const max = input.max_employees as number | undefined
|
|
555
|
+
const ranges: Array<[string, number, number]> = [
|
|
556
|
+
['1', 1, 1],
|
|
557
|
+
['2-10', 2, 10],
|
|
558
|
+
['11-50', 11, 50],
|
|
559
|
+
['51-200', 51, 200],
|
|
560
|
+
['201-500', 201, 500],
|
|
561
|
+
['501-1000', 501, 1000],
|
|
562
|
+
['1001-5000', 1001, 5000],
|
|
563
|
+
['5001-10000', 5001, 10000],
|
|
564
|
+
['10001+', 10001, Number.MAX_SAFE_INTEGER],
|
|
565
|
+
]
|
|
566
|
+
for (const [label, lo, hi] of ranges) {
|
|
567
|
+
if (hi >= (min ?? 0) && lo <= (max ?? Number.MAX_SAFE_INTEGER)) sizeRanges.push(label)
|
|
568
|
+
}
|
|
569
|
+
const locations = [
|
|
570
|
+
...((input.locations as string[] | undefined) ?? []),
|
|
571
|
+
...((input.countries as string[] | undefined) ?? []),
|
|
572
|
+
]
|
|
573
|
+
const queryParams: Record<string, string> = {
|
|
574
|
+
location: locations[0],
|
|
575
|
+
// Skip `sizes` when the range covers all 9 buckets (effectively no filter)
|
|
576
|
+
...(sizeRanges.length < 9 ? { sizes: sizeRanges.join(',') } : {}),
|
|
577
|
+
limit: String((input.limit as number) ?? 25),
|
|
578
|
+
}
|
|
579
|
+
return { queryParams }
|
|
580
|
+
},
|
|
581
|
+
hasResult: (data) => {
|
|
582
|
+
const d = data as Record<string, unknown>
|
|
583
|
+
return isNonEmptyArray(d.data) || isNonEmptyArray(d.companies) || isNonEmptyArray(d.results)
|
|
584
|
+
},
|
|
585
|
+
isApplicable: (input) => {
|
|
586
|
+
// Backend schema requires `location` AND `sizes`. Need at least a country/location
|
|
587
|
+
// and an employee range — otherwise the call always fails Zod.
|
|
588
|
+
const hasLocation = isNonEmptyArray(input.locations) || isNonEmptyArray(input.countries)
|
|
589
|
+
const hasSize = typeof input.min_employees === 'number' || typeof input.max_employees === 'number'
|
|
590
|
+
return hasLocation && hasSize
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
id: 'theirstack',
|
|
595
|
+
endpoint: '/theirstack/companies/search',
|
|
596
|
+
method: 'POST',
|
|
597
|
+
priority: 4,
|
|
598
|
+
isApplicable: (input) =>
|
|
599
|
+
isNonEmptyArray(input.technologies) ||
|
|
600
|
+
isNonEmptyArray(input.industries) ||
|
|
601
|
+
isNonEmptyArray(input.funding_stages) ||
|
|
602
|
+
typeof input.min_funding_amount === 'number' ||
|
|
603
|
+
typeof input.max_funding_amount === 'number',
|
|
604
|
+
mapParams: (input) => ({
|
|
605
|
+
body: {
|
|
606
|
+
company_technology_slug_or: isNonEmptyArray(input.technologies) ? input.technologies : undefined,
|
|
607
|
+
industry_or: input.industries,
|
|
608
|
+
company_country_code_or: input.countries,
|
|
609
|
+
min_employee_count: input.min_employees,
|
|
610
|
+
max_employee_count: input.max_employees,
|
|
611
|
+
funding_stage_or: isNonEmptyArray(input.funding_stages) ? input.funding_stages : undefined,
|
|
612
|
+
min_funding_usd: input.min_funding_amount,
|
|
613
|
+
max_funding_usd: input.max_funding_amount,
|
|
614
|
+
limit: (input.limit as number) ?? 25,
|
|
615
|
+
},
|
|
616
|
+
}),
|
|
617
|
+
hasResult: (data) => {
|
|
618
|
+
const d = data as Record<string, unknown>
|
|
619
|
+
return isNonEmptyArray(d.data) || isNonEmptyArray(d.companies) || isNonEmptyArray(d.results)
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
id: 'sumble',
|
|
624
|
+
endpoint: '/sumble/organizations/find',
|
|
625
|
+
method: 'POST',
|
|
626
|
+
priority: 11,
|
|
627
|
+
mapParams: (input) => {
|
|
628
|
+
const keywords = [
|
|
629
|
+
...((input.keywords as string[] | undefined) ?? []),
|
|
630
|
+
...((input.industries as string[] | undefined) ?? []),
|
|
631
|
+
]
|
|
632
|
+
const filtersObj: Record<string, unknown> = {}
|
|
633
|
+
if (keywords.length > 0) filtersObj.query = keywords.join(' ')
|
|
634
|
+
if (isNonEmptyArray(input.technologies)) filtersObj.technologies = input.technologies
|
|
635
|
+
return {
|
|
636
|
+
body: {
|
|
637
|
+
filters: filtersObj,
|
|
638
|
+
limit: (input.limit as number) ?? 25,
|
|
639
|
+
},
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
hasResult: (data) => {
|
|
643
|
+
const d = data as Record<string, unknown>
|
|
644
|
+
return isNonEmptyArray(d.organizations) || isNonEmptyArray(d.data) || isNonEmptyArray(d.results)
|
|
645
|
+
},
|
|
646
|
+
isApplicable: (input) =>
|
|
647
|
+
isNonEmptyArray(input.keywords) ||
|
|
648
|
+
isNonEmptyArray(input.industries) ||
|
|
649
|
+
isNonEmptyArray(input.technologies),
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
id: 'wiza',
|
|
653
|
+
endpoint: '/wiza/prospects/create-prospect-list',
|
|
654
|
+
method: 'POST',
|
|
655
|
+
priority: 5,
|
|
656
|
+
isApplicable: (input) =>
|
|
657
|
+
typeof input.min_founded_year === 'number' ||
|
|
658
|
+
typeof input.max_founded_year === 'number' ||
|
|
659
|
+
isNonEmptyArray(input.funding_stages) ||
|
|
660
|
+
typeof input.min_funding_amount === 'number' ||
|
|
661
|
+
typeof input.max_funding_amount === 'number',
|
|
662
|
+
mapParams: (input) => {
|
|
663
|
+
// Wiza filters use [{v: 'value'}] shape. Wiza's prospect endpoints return *people*,
|
|
664
|
+
// but create-prospect-list also returns the company set indirectly via its filters.
|
|
665
|
+
// For a search_companies waterfall, we treat a non-empty `stats.people` as proof
|
|
666
|
+
// that the requested filter set matched at least one company.
|
|
667
|
+
const wrap = (vals?: string[]) => (Array.isArray(vals) && vals.length > 0 ? vals.map((v) => ({ v })) : undefined)
|
|
668
|
+
const filters: Record<string, unknown> = {}
|
|
669
|
+
const industries = [
|
|
670
|
+
...((input.industries as string[] | undefined) ?? []),
|
|
671
|
+
...((input.keywords as string[] | undefined) ?? []),
|
|
672
|
+
]
|
|
673
|
+
// Wiza company_location accepts free-text English names — translate ISO codes.
|
|
674
|
+
const locations = [
|
|
675
|
+
...((input.locations as string[] | undefined) ?? []),
|
|
676
|
+
...((input.countries as string[] | undefined) ?? []).map(isoCountryToName),
|
|
677
|
+
]
|
|
678
|
+
if (industries.length > 0) filters.company_industry = wrap(industries)
|
|
679
|
+
if (locations.length > 0) filters.company_location = wrap(locations)
|
|
680
|
+
if (typeof input.min_founded_year === 'number') filters.year_founded_start = wrap([String(input.min_founded_year)])
|
|
681
|
+
if (typeof input.max_founded_year === 'number') filters.year_founded_end = wrap([String(input.max_founded_year)])
|
|
682
|
+
if (isNonEmptyArray(input.funding_stages)) filters.funding_stage = wrap(input.funding_stages as string[])
|
|
683
|
+
if (typeof input.min_funding_amount === 'number') filters.funding_min = wrap([String(input.min_funding_amount)])
|
|
684
|
+
if (typeof input.max_funding_amount === 'number') filters.funding_max = wrap([String(input.max_funding_amount)])
|
|
685
|
+
const limit = Math.min((input.limit as number) ?? 25, 500)
|
|
686
|
+
return {
|
|
687
|
+
body: {
|
|
688
|
+
list: { name: 'mcp-search-companies', max_profiles: limit },
|
|
689
|
+
filters,
|
|
690
|
+
enrichment_level: 'none',
|
|
691
|
+
},
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
hasResult: (data) => {
|
|
695
|
+
// Final poll response: { status, type, data: { id, status: 'finished', stats: { people } } }
|
|
696
|
+
const d = data as Record<string, unknown>
|
|
697
|
+
const inner = d.data as Record<string, unknown> | undefined
|
|
698
|
+
const stats = inner?.stats as Record<string, unknown> | undefined
|
|
699
|
+
const people = stats?.people as number | undefined
|
|
700
|
+
return typeof people === 'number' && people > 0
|
|
701
|
+
},
|
|
702
|
+
async: {
|
|
703
|
+
extractId: (response) => {
|
|
704
|
+
const r = response as Record<string, unknown>
|
|
705
|
+
const inner = r.data as Record<string, unknown> | undefined
|
|
706
|
+
const id = inner?.id
|
|
707
|
+
if (typeof id === 'number') return String(id)
|
|
708
|
+
if (typeof id === 'string' && id.length > 0) return id
|
|
709
|
+
throw new Error('Wiza response has no list id')
|
|
710
|
+
},
|
|
711
|
+
pollEndpoint: (id) => `/wiza/lists/${id}`,
|
|
712
|
+
pollIntervalMs: 10_000,
|
|
713
|
+
timeoutMs: 300_000, // 5 min
|
|
714
|
+
isComplete: (data) => {
|
|
715
|
+
// Wiza uses different status strings across endpoints — accept all known terminal
|
|
716
|
+
// states (`finished` for individual reveals, `completed` for lists, plus failures).
|
|
717
|
+
const d = data as Record<string, unknown>
|
|
718
|
+
const inner = d.data as Record<string, unknown> | undefined
|
|
719
|
+
const status = inner?.status as string | undefined
|
|
720
|
+
return status === 'finished' || status === 'completed' || status === 'failed'
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
id: 'limadata-prospect-filter',
|
|
726
|
+
endpoint: '/coldiq/prospect/companies/filter',
|
|
727
|
+
method: 'POST',
|
|
728
|
+
priority: 12,
|
|
729
|
+
mapParams: (input) => {
|
|
730
|
+
// LimaData prospect filter uses [{filter_type, values}]. We can only reliably
|
|
731
|
+
// map company_headcount (size buckets); industries/locations need LinkedIn IDs.
|
|
732
|
+
const filters: Array<{ filter_type: string; values: string[] }> = []
|
|
733
|
+
const sizeBuckets = mapEmployeesToLimaDataHeadcount(
|
|
734
|
+
input.min_employees as number | undefined,
|
|
735
|
+
input.max_employees as number | undefined,
|
|
736
|
+
)
|
|
737
|
+
if (sizeBuckets.length > 0 && sizeBuckets.length < 8) {
|
|
738
|
+
filters.push({ filter_type: 'company_headcount', values: sizeBuckets })
|
|
739
|
+
}
|
|
740
|
+
return { body: { filters } }
|
|
741
|
+
},
|
|
742
|
+
hasResult: (data) => {
|
|
743
|
+
const d = data as Record<string, unknown>
|
|
744
|
+
return isNonEmptyArray(d.data) || isNonEmptyArray(d.companies) || isNonEmptyArray(d.results)
|
|
745
|
+
},
|
|
746
|
+
isApplicable: (input) => {
|
|
747
|
+
// 25 cr/call, charged on success regardless of result count — only fire when
|
|
748
|
+
// the employee range maps to a non-empty, narrowing bucket set.
|
|
749
|
+
const buckets = mapEmployeesToLimaDataHeadcount(
|
|
750
|
+
input.min_employees as number | undefined,
|
|
751
|
+
input.max_employees as number | undefined,
|
|
752
|
+
)
|
|
753
|
+
return buckets.length > 0 && buckets.length < 8
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
id: 'limadata-prospect-url',
|
|
758
|
+
endpoint: '/coldiq/prospect/companies/search-url',
|
|
759
|
+
method: 'POST',
|
|
760
|
+
priority: 13,
|
|
761
|
+
mapParams: (input) => ({
|
|
762
|
+
body: { search_url: input.linkedin_search_url as string },
|
|
763
|
+
}),
|
|
764
|
+
hasResult: (data) => {
|
|
765
|
+
const d = data as Record<string, unknown>
|
|
766
|
+
return isNonEmptyArray(d.data) || isNonEmptyArray(d.companies) || isNonEmptyArray(d.results)
|
|
767
|
+
},
|
|
768
|
+
isApplicable: (input) => typeof input.linkedin_search_url === 'string' && input.linkedin_search_url.length > 0,
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
id: 'linkupapi-search',
|
|
772
|
+
endpoint: '/linkupapi/data/search/companies',
|
|
773
|
+
method: 'POST',
|
|
774
|
+
priority: 14,
|
|
775
|
+
mapParams: (input) => {
|
|
776
|
+
const locations = [
|
|
777
|
+
...((input.locations as string[] | undefined) ?? []),
|
|
778
|
+
...((input.countries as string[] | undefined) ?? []).map(isoCountryToName),
|
|
779
|
+
]
|
|
780
|
+
const minE = input.min_employees as number | undefined
|
|
781
|
+
const maxE = input.max_employees as number | undefined
|
|
782
|
+
const employeeRange =
|
|
783
|
+
typeof minE === 'number' && typeof maxE === 'number' ? `${minE}-${maxE}` : undefined
|
|
784
|
+
return {
|
|
785
|
+
body: {
|
|
786
|
+
keyword: (input.keywords as string[] | undefined)?.join(' ') || undefined,
|
|
787
|
+
industry: isNonEmptyArray(input.industries) ? (input.industries as string[]) : undefined,
|
|
788
|
+
location: locations.length > 0 ? locations : undefined,
|
|
789
|
+
employee_range: employeeRange,
|
|
790
|
+
total_results: Math.min((input.limit as number) ?? 25, 25),
|
|
791
|
+
},
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
hasResult: (data) => {
|
|
795
|
+
const d = data as Record<string, unknown>
|
|
796
|
+
const inner = d.data as Record<string, unknown> | undefined
|
|
797
|
+
return isNonEmptyArray(inner?.companies)
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
id: 'linkupapi-fundraising',
|
|
802
|
+
endpoint: '/linkupapi/data/fundraising-companies',
|
|
803
|
+
method: 'POST',
|
|
804
|
+
priority: 15,
|
|
805
|
+
isApplicable: (input) => {
|
|
806
|
+
const hasFundingFilter =
|
|
807
|
+
isNonEmptyArray(input.funding_stages) ||
|
|
808
|
+
typeof input.min_funding_amount === 'number' ||
|
|
809
|
+
typeof input.min_funding_year === 'number'
|
|
810
|
+
// Linkup fundraising requires a keyword — fire only when we have text to send
|
|
811
|
+
const hasText = isNonEmptyArray(input.keywords) || isNonEmptyArray(input.industries)
|
|
812
|
+
return hasFundingFilter && hasText
|
|
813
|
+
},
|
|
814
|
+
mapParams: (input) => {
|
|
815
|
+
const stages = (input.funding_stages as string[] | undefined) ?? []
|
|
816
|
+
const mappedStage = stages.length > 0 ? toLinkupFundingStage(stages[0]) : undefined
|
|
817
|
+
const keyword = [
|
|
818
|
+
...((input.keywords as string[] | undefined) ?? []),
|
|
819
|
+
...((input.industries as string[] | undefined) ?? []),
|
|
820
|
+
].join(' ')
|
|
821
|
+
const minE = input.min_employees as number | undefined
|
|
822
|
+
const maxE = input.max_employees as number | undefined
|
|
823
|
+
const employeeRange =
|
|
824
|
+
typeof minE === 'number' && typeof maxE === 'number' ? `${minE}-${maxE}` : undefined
|
|
825
|
+
return {
|
|
826
|
+
body: {
|
|
827
|
+
keyword,
|
|
828
|
+
funding_stage: mappedStage,
|
|
829
|
+
min_funding_amount: input.min_funding_amount,
|
|
830
|
+
max_funding_amount: input.max_funding_amount,
|
|
831
|
+
industry: (input.industries as string[] | undefined)?.[0],
|
|
832
|
+
location: (input.locations as string[] | undefined)?.[0],
|
|
833
|
+
employee_range: employeeRange,
|
|
834
|
+
total_results: Math.min((input.limit as number) ?? 25, 25),
|
|
835
|
+
},
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
hasResult: (data) => {
|
|
839
|
+
const d = data as Record<string, unknown>
|
|
840
|
+
const inner = d.data as Record<string, unknown> | undefined
|
|
841
|
+
return isNonEmptyArray(inner?.companies)
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
id: 'linkupapi-hiring',
|
|
846
|
+
endpoint: '/linkupapi/data/hiring-companies',
|
|
847
|
+
method: 'POST',
|
|
848
|
+
priority: 16,
|
|
849
|
+
isApplicable: (input) => input.is_hiring === true,
|
|
850
|
+
mapParams: (input) => {
|
|
851
|
+
const minE = input.min_employees as number | undefined
|
|
852
|
+
const maxE = input.max_employees as number | undefined
|
|
853
|
+
const employeeRange =
|
|
854
|
+
typeof minE === 'number' && typeof maxE === 'number' ? `${minE}-${maxE}` : undefined
|
|
855
|
+
return {
|
|
856
|
+
body: {
|
|
857
|
+
industry: (input.industries as string[] | undefined)?.[0],
|
|
858
|
+
location:
|
|
859
|
+
(input.locations as string[] | undefined)?.[0] ??
|
|
860
|
+
(input.countries as string[] | undefined)?.map(isoCountryToName)[0],
|
|
861
|
+
employee_range: employeeRange,
|
|
862
|
+
total_results: Math.min((input.limit as number) ?? 25, 25),
|
|
863
|
+
},
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
hasResult: (data) => {
|
|
867
|
+
const d = data as Record<string, unknown>
|
|
868
|
+
const inner = d.data as Record<string, unknown> | undefined
|
|
869
|
+
return isNonEmptyArray(inner?.companies)
|
|
870
|
+
},
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
id: 'prospeo-search-company',
|
|
874
|
+
endpoint: '/prospeo/search-company',
|
|
875
|
+
method: 'POST',
|
|
876
|
+
priority: 17,
|
|
877
|
+
isApplicable: (input) =>
|
|
878
|
+
isNonEmptyArray(input.keywords) ||
|
|
879
|
+
isNonEmptyArray(input.industries) ||
|
|
880
|
+
isNonEmptyArray(input.countries),
|
|
881
|
+
mapParams: (input) => ({
|
|
882
|
+
body: {
|
|
883
|
+
filters: {
|
|
884
|
+
...(isNonEmptyArray(input.keywords) && {
|
|
885
|
+
company_keywords: { include: input.keywords },
|
|
886
|
+
}),
|
|
887
|
+
...(isNonEmptyArray(input.industries) && {
|
|
888
|
+
company_industry: { include: input.industries },
|
|
889
|
+
}),
|
|
890
|
+
...(isNonEmptyArray(input.countries) && {
|
|
891
|
+
company_country: { include: (input.countries as string[]).map(isoCountryToName) },
|
|
892
|
+
}),
|
|
893
|
+
...((input.min_employees !== undefined || input.max_employees !== undefined) && {
|
|
894
|
+
company_headcount_custom: {
|
|
895
|
+
min: input.min_employees,
|
|
896
|
+
max: input.max_employees,
|
|
897
|
+
},
|
|
898
|
+
}),
|
|
899
|
+
},
|
|
900
|
+
},
|
|
901
|
+
}),
|
|
902
|
+
hasResult: (data) => {
|
|
903
|
+
const d = data as Record<string, unknown>
|
|
904
|
+
return isNonEmptyArray(d.data)
|
|
905
|
+
},
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
id: 'ai-ark-companies',
|
|
909
|
+
endpoint: '/ai-ark/companies',
|
|
910
|
+
method: 'POST',
|
|
911
|
+
priority: 18,
|
|
912
|
+
isApplicable: (input) =>
|
|
913
|
+
isNonEmptyArray(input.keywords) ||
|
|
914
|
+
isNonEmptyArray(input.industries) ||
|
|
915
|
+
isNonEmptyArray(input.countries),
|
|
916
|
+
mapParams: (input) => ({
|
|
917
|
+
body: {
|
|
918
|
+
account: {
|
|
919
|
+
...(isNonEmptyArray(input.industries) && {
|
|
920
|
+
industries: { any: { include: input.industries } },
|
|
921
|
+
}),
|
|
922
|
+
...(isNonEmptyArray(input.countries) && {
|
|
923
|
+
location: { any: { include: (input.countries as string[]).map(isoCountryToName) } },
|
|
924
|
+
}),
|
|
925
|
+
...((input.min_employees !== undefined || input.max_employees !== undefined) && {
|
|
926
|
+
employeeSize: {
|
|
927
|
+
type: 'RANGE',
|
|
928
|
+
range: [{ start: input.min_employees, end: input.max_employees }],
|
|
929
|
+
},
|
|
930
|
+
}),
|
|
931
|
+
...(isNonEmptyArray(input.funding_stages) && {
|
|
932
|
+
funding: { stage: { include: input.funding_stages } },
|
|
933
|
+
}),
|
|
934
|
+
},
|
|
935
|
+
...(isNonEmptyArray(input.keywords) && {
|
|
936
|
+
contact: {
|
|
937
|
+
keywordsInProfile: { any: { include: input.keywords } },
|
|
938
|
+
},
|
|
939
|
+
}),
|
|
940
|
+
size: Math.min((input.limit as number | undefined) ?? 10, 100),
|
|
941
|
+
},
|
|
942
|
+
}),
|
|
943
|
+
hasResult: (data) => {
|
|
944
|
+
const d = data as Record<string, unknown>
|
|
945
|
+
return isNonEmptyArray(d.content)
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
]
|
|
949
|
+
|
|
950
|
+
// ---------------------------------------------------------------------------
|
|
951
|
+
// find_people
|
|
952
|
+
// ---------------------------------------------------------------------------
|
|
953
|
+
|
|
954
|
+
// Level-indicator prefixes sorted longest-first to avoid partial matches.
|
|
955
|
+
const TITLE_LEVEL_PREFIXES = [
|
|
956
|
+
'executive vice president', 'senior vice president', 'managing director',
|
|
957
|
+
'vice president', 'president of',
|
|
958
|
+
'director of the', 'head of the',
|
|
959
|
+
'director of', 'head of', 'manager of', 'chief of', 'president',
|
|
960
|
+
'director', 'head', 'manager', 'chief',
|
|
961
|
+
'vp of', 'sr vp',
|
|
962
|
+
'vp', 'svp', 'evp', 'avp',
|
|
963
|
+
'senior', 'sr', 'lead',
|
|
964
|
+
]
|
|
965
|
+
|
|
966
|
+
// C-suite abbreviations map to a stable domain key so "CEO" and
|
|
967
|
+
// "Chief Executive Officer" collapse into the same persona group.
|
|
968
|
+
const TITLE_C_SUITE_DOMAIN: Record<string, string> = {
|
|
969
|
+
ceo: '__ceo__', cfo: '__cfo__', cto: '__cto__', cmo: '__cmo__',
|
|
970
|
+
cro: '__cro__', coo: '__coo__', cpo: '__cpo__', ciso: '__ciso__',
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function titleDomainKey(title: string): string {
|
|
974
|
+
const t = title.toLowerCase().trim()
|
|
975
|
+
if (TITLE_C_SUITE_DOMAIN[t]) return TITLE_C_SUITE_DOMAIN[t]
|
|
976
|
+
// "Chief X Officer" → domain is X
|
|
977
|
+
const chiefMatch = t.match(/^chief\s+(.+?)\s+officer$/)
|
|
978
|
+
if (chiefMatch) return chiefMatch[1].trim()
|
|
979
|
+
for (const prefix of TITLE_LEVEL_PREFIXES) {
|
|
980
|
+
if (t.startsWith(prefix + ' ')) {
|
|
981
|
+
return t.slice(prefix.length + 1).replace(/^(of|the)\s+/, '').trim()
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return t
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const findPeopleProviders: ProviderEntry[] = [
|
|
988
|
+
{
|
|
989
|
+
id: 'leadsfactory',
|
|
990
|
+
endpoint: '/leadsfactory/contact-finder/searches',
|
|
991
|
+
method: 'POST',
|
|
992
|
+
priority: 1,
|
|
993
|
+
mapParams: (input) => {
|
|
994
|
+
const rawTitles = (input.job_titles as string[] | undefined) ?? []
|
|
995
|
+
// Step 1: deduplicate near-identical "of" variants (e.g. "VP Sales" ≡ "VP of Sales")
|
|
996
|
+
const normalizeOf = (t: string) =>
|
|
997
|
+
t.toLowerCase().replace(/\s+of\s+/g, ' ').replace(/\s+/g, ' ').trim()
|
|
998
|
+
const seenNorm = new Set<string>()
|
|
999
|
+
const deduped = rawTitles
|
|
1000
|
+
.map((t) => t.trim()).filter((t) => t.length > 0)
|
|
1001
|
+
.filter((t) => { const n = normalizeOf(t); if (seenNorm.has(n)) return false; seenNorm.add(n); return true })
|
|
1002
|
+
// Step 2: group by domain — same domain (e.g. "VP Sales" + "Head of Sales" → "sales")
|
|
1003
|
+
// gets one persona with comma-joined titles; different domains get separate personas.
|
|
1004
|
+
const groups = new Map<string, string[]>()
|
|
1005
|
+
for (const title of deduped) {
|
|
1006
|
+
const key = titleDomainKey(title)
|
|
1007
|
+
const g = groups.get(key)
|
|
1008
|
+
if (g) g.push(title)
|
|
1009
|
+
else groups.set(key, [title])
|
|
1010
|
+
}
|
|
1011
|
+
const personas =
|
|
1012
|
+
groups.size > 0
|
|
1013
|
+
? [...groups.values()].map((titles) => ({ job_title: titles.join(', ') }))
|
|
1014
|
+
: [{ job_title: 'Decision Maker' }]
|
|
1015
|
+
const hasLinkedInUrls = isNonEmptyArray(input.company_linkedin_urls)
|
|
1016
|
+
return {
|
|
1017
|
+
body: {
|
|
1018
|
+
...(hasLinkedInUrls && { company_linkedin_urls: input.company_linkedin_urls }),
|
|
1019
|
+
// Only pass domains when no LinkedIn URLs — LF uses LinkedIn URLs as the faster
|
|
1020
|
+
// primary identifier; mixing both slows the search. Domains-only path also enables
|
|
1021
|
+
// no_results_domains gap-fill with Apollo.
|
|
1022
|
+
...(!hasLinkedInUrls && isNonEmptyArray(input.company_domains) && { company_domains: input.company_domains }),
|
|
1023
|
+
search: {
|
|
1024
|
+
max_persona_results: (input.limit as number) ?? 25,
|
|
1025
|
+
personas,
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1028
|
+
}
|
|
1029
|
+
},
|
|
1030
|
+
hasResult: (data) => {
|
|
1031
|
+
const d = data as Record<string, unknown>
|
|
1032
|
+
return (
|
|
1033
|
+
isNonEmptyArray(d.companies_personas) ||
|
|
1034
|
+
(d.status === 'SUCCESSFUL' && typeof d.nb_jobs_complete === 'number' && (d.nb_jobs_complete as number) > 0)
|
|
1035
|
+
)
|
|
1036
|
+
},
|
|
1037
|
+
async: {
|
|
1038
|
+
pollEndpoint: (id: string) => `/leadsfactory/contact-finder/searches/${id}`,
|
|
1039
|
+
pollIntervalMs: 15000,
|
|
1040
|
+
timeoutMs: 300_000, // 5 minutes
|
|
1041
|
+
isComplete: (data) => {
|
|
1042
|
+
const d = data as Record<string, unknown>
|
|
1043
|
+
const status = d.status as string | undefined
|
|
1044
|
+
return status === 'SUCCESSFUL' || status === 'FAILED'
|
|
1045
|
+
},
|
|
1046
|
+
extractId: (response) => {
|
|
1047
|
+
const d = response as Record<string, unknown>
|
|
1048
|
+
return (d._id as string) ?? (d.search_id as string)
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
id: 'apollo',
|
|
1054
|
+
endpoint: '/apollo/people/search',
|
|
1055
|
+
method: 'POST',
|
|
1056
|
+
priority: 2,
|
|
1057
|
+
mapParams: (input) => ({
|
|
1058
|
+
body: {
|
|
1059
|
+
person_titles: input.job_titles,
|
|
1060
|
+
q_organization_domains: input.company_domains,
|
|
1061
|
+
person_seniorities: input.seniorities,
|
|
1062
|
+
person_locations: input.locations,
|
|
1063
|
+
q_keywords: (input.keywords as string[] | undefined)?.join(' ') || undefined,
|
|
1064
|
+
per_page: (input.limit as number) ?? 25,
|
|
1065
|
+
},
|
|
1066
|
+
}),
|
|
1067
|
+
hasResult: (data) => {
|
|
1068
|
+
const d = data as Record<string, unknown>
|
|
1069
|
+
return isNonEmptyArray(d.people) || isNonEmptyArray(d.contacts)
|
|
1070
|
+
},
|
|
1071
|
+
},
|
|
1072
|
+
{
|
|
1073
|
+
id: 'pdl',
|
|
1074
|
+
endpoint: '/pdl/person/search',
|
|
1075
|
+
method: 'POST',
|
|
1076
|
+
priority: 3,
|
|
1077
|
+
mapParams: (input) => {
|
|
1078
|
+
const must: unknown[] = []
|
|
1079
|
+
if (isNonEmptyArray(input.job_titles))
|
|
1080
|
+
must.push({ terms: { job_title: input.job_titles } })
|
|
1081
|
+
if (isNonEmptyArray(input.company_domains))
|
|
1082
|
+
must.push({ terms: { job_company_website: input.company_domains } })
|
|
1083
|
+
if (isNonEmptyArray(input.seniorities))
|
|
1084
|
+
must.push({ terms: { job_title_levels: input.seniorities } })
|
|
1085
|
+
if (isNonEmptyArray(input.locations))
|
|
1086
|
+
must.push({ terms: { location_country: input.locations } })
|
|
1087
|
+
return {
|
|
1088
|
+
body: {
|
|
1089
|
+
query: must.length > 0 ? { bool: { must } } : undefined,
|
|
1090
|
+
size: (input.limit as number) ?? 25,
|
|
1091
|
+
},
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
hasResult: (data) => {
|
|
1095
|
+
const d = data as Record<string, unknown>
|
|
1096
|
+
return isNonEmptyArray(d.data) || (typeof d.total === 'number' && d.total > 0)
|
|
1097
|
+
},
|
|
1098
|
+
},
|
|
1099
|
+
{
|
|
1100
|
+
id: 'companyenrich',
|
|
1101
|
+
endpoint: '/companyenrich/people/search',
|
|
1102
|
+
method: 'POST',
|
|
1103
|
+
priority: 4,
|
|
1104
|
+
mapParams: (input) => ({
|
|
1105
|
+
body: {
|
|
1106
|
+
filters: {
|
|
1107
|
+
jobTitles: input.job_titles,
|
|
1108
|
+
countries: input.locations,
|
|
1109
|
+
},
|
|
1110
|
+
pageSize: (input.limit as number) ?? 25,
|
|
1111
|
+
},
|
|
1112
|
+
}),
|
|
1113
|
+
hasResult: (data) => {
|
|
1114
|
+
const d = data as Record<string, unknown>
|
|
1115
|
+
return isNonEmptyArray(d.data) || isNonEmptyArray(d.results) || isNonEmptyArray(d.people)
|
|
1116
|
+
},
|
|
1117
|
+
},
|
|
1118
|
+
{
|
|
1119
|
+
id: 'linkupapi-search-profiles',
|
|
1120
|
+
endpoint: '/linkupapi/data/search/profiles',
|
|
1121
|
+
method: 'POST',
|
|
1122
|
+
priority: 5,
|
|
1123
|
+
// Linkup search-profiles accepts company names, not domains/LinkedIn URLs.
|
|
1124
|
+
// Skip when the caller has specific company identifiers — earlier providers handle those better.
|
|
1125
|
+
isApplicable: (input) =>
|
|
1126
|
+
!isNonEmptyArray(input.company_linkedin_urls) && !isNonEmptyArray(input.company_domains),
|
|
1127
|
+
mapParams: (input) => ({
|
|
1128
|
+
body: {
|
|
1129
|
+
keyword: (input.keywords as string[] | undefined)?.join(' ') || undefined,
|
|
1130
|
+
job_title: isNonEmptyArray(input.job_titles) ? (input.job_titles as string[]) : undefined,
|
|
1131
|
+
location: isNonEmptyArray(input.locations) ? (input.locations as string[]) : undefined,
|
|
1132
|
+
total_results: Math.min((input.limit as number) ?? 25, 25),
|
|
1133
|
+
},
|
|
1134
|
+
}),
|
|
1135
|
+
hasResult: (data) => {
|
|
1136
|
+
const d = data as Record<string, unknown>
|
|
1137
|
+
const inner = d.data as Record<string, unknown> | undefined
|
|
1138
|
+
return isNonEmptyArray(inner?.profiles)
|
|
1139
|
+
},
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
id: 'sumble-people-find',
|
|
1143
|
+
endpoint: '/sumble/people/find',
|
|
1144
|
+
method: 'POST',
|
|
1145
|
+
priority: 6,
|
|
1146
|
+
// Requires a company identifier; otherwise Sumble has nothing to scope the search against.
|
|
1147
|
+
isApplicable: (input) =>
|
|
1148
|
+
(isNonEmptyArray(input.company_domains) || isNonEmptyArray(input.company_linkedin_urls)) &&
|
|
1149
|
+
(isNonEmptyArray(input.job_titles) || isNonEmptyArray(input.seniorities)),
|
|
1150
|
+
mapParams: (input) => {
|
|
1151
|
+
const domains = input.company_domains as string[] | undefined
|
|
1152
|
+
const urls = input.company_linkedin_urls as string[] | undefined
|
|
1153
|
+
const org = domains?.[0] ? { domain: domains[0] } : { linkedin_url: urls?.[0] }
|
|
1154
|
+
return {
|
|
1155
|
+
body: {
|
|
1156
|
+
organization: org,
|
|
1157
|
+
filters: {
|
|
1158
|
+
job_functions: isNonEmptyArray(input.job_titles) ? input.job_titles : undefined,
|
|
1159
|
+
job_levels: isNonEmptyArray(input.seniorities) ? input.seniorities : undefined,
|
|
1160
|
+
countries: isNonEmptyArray(input.locations) ? input.locations : undefined,
|
|
1161
|
+
query: isNonEmptyArray(input.keywords)
|
|
1162
|
+
? (input.keywords as string[]).join(' ')
|
|
1163
|
+
: undefined,
|
|
1164
|
+
},
|
|
1165
|
+
limit: Math.min((input.limit as number | undefined) ?? 10, 100),
|
|
1166
|
+
},
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
hasResult: (data) => {
|
|
1170
|
+
const d = data as Record<string, unknown>
|
|
1171
|
+
return isNonEmptyArray(d.people)
|
|
1172
|
+
},
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
id: 'prospeo-search-person',
|
|
1176
|
+
endpoint: '/prospeo/search-person',
|
|
1177
|
+
method: 'POST',
|
|
1178
|
+
priority: 7,
|
|
1179
|
+
isApplicable: (input) =>
|
|
1180
|
+
isNonEmptyArray(input.job_titles) ||
|
|
1181
|
+
isNonEmptyArray(input.company_domains),
|
|
1182
|
+
mapParams: (input) => ({
|
|
1183
|
+
body: {
|
|
1184
|
+
filters: {
|
|
1185
|
+
...(isNonEmptyArray(input.job_titles) && {
|
|
1186
|
+
job_title: { include: input.job_titles },
|
|
1187
|
+
}),
|
|
1188
|
+
...(isNonEmptyArray(input.seniorities) && {
|
|
1189
|
+
person_seniority: { include: input.seniorities },
|
|
1190
|
+
}),
|
|
1191
|
+
...(isNonEmptyArray(input.company_domains) && {
|
|
1192
|
+
company: { websites: { include: input.company_domains } },
|
|
1193
|
+
}),
|
|
1194
|
+
...(isNonEmptyArray(input.locations) && {
|
|
1195
|
+
person_location: { include: input.locations },
|
|
1196
|
+
}),
|
|
1197
|
+
},
|
|
1198
|
+
},
|
|
1199
|
+
}),
|
|
1200
|
+
hasResult: (data) => {
|
|
1201
|
+
const d = data as Record<string, unknown>
|
|
1202
|
+
return isNonEmptyArray(d.data)
|
|
1203
|
+
},
|
|
1204
|
+
},
|
|
1205
|
+
{
|
|
1206
|
+
id: 'ai-ark-people',
|
|
1207
|
+
endpoint: '/ai-ark/people',
|
|
1208
|
+
method: 'POST',
|
|
1209
|
+
priority: 8,
|
|
1210
|
+
isApplicable: (input) =>
|
|
1211
|
+
isNonEmptyArray(input.job_titles) ||
|
|
1212
|
+
isNonEmptyArray(input.seniorities) ||
|
|
1213
|
+
isNonEmptyArray(input.keywords),
|
|
1214
|
+
mapParams: (input) => ({
|
|
1215
|
+
body: {
|
|
1216
|
+
contact: {
|
|
1217
|
+
...(isNonEmptyArray(input.job_titles) && {
|
|
1218
|
+
departmentAndFunction: { any: { include: input.job_titles } },
|
|
1219
|
+
}),
|
|
1220
|
+
...(isNonEmptyArray(input.seniorities) && {
|
|
1221
|
+
seniority: { any: { include: input.seniorities } },
|
|
1222
|
+
}),
|
|
1223
|
+
...(isNonEmptyArray(input.locations) && {
|
|
1224
|
+
location: { any: { include: input.locations } },
|
|
1225
|
+
}),
|
|
1226
|
+
...(isNonEmptyArray(input.keywords) && {
|
|
1227
|
+
keywordsInProfile: { any: { include: input.keywords } },
|
|
1228
|
+
}),
|
|
1229
|
+
},
|
|
1230
|
+
size: Math.min((input.limit as number | undefined) ?? 10, 100),
|
|
1231
|
+
},
|
|
1232
|
+
}),
|
|
1233
|
+
hasResult: (data) => {
|
|
1234
|
+
const d = data as Record<string, unknown>
|
|
1235
|
+
return isNonEmptyArray(d.content)
|
|
1236
|
+
},
|
|
1237
|
+
},
|
|
1238
|
+
{
|
|
1239
|
+
id: 'fullenrich-people-search',
|
|
1240
|
+
endpoint: '/fullenrich/people/search',
|
|
1241
|
+
method: 'POST',
|
|
1242
|
+
priority: 9,
|
|
1243
|
+
mapParams: (input) => ({
|
|
1244
|
+
body: {
|
|
1245
|
+
current_company_domains: isNonEmptyArray(input.company_domains)
|
|
1246
|
+
? (input.company_domains as string[]).map((v) => ({ value: v }))
|
|
1247
|
+
: undefined,
|
|
1248
|
+
current_position_titles: isNonEmptyArray(input.job_titles)
|
|
1249
|
+
? (input.job_titles as string[]).map((v) => ({ value: v }))
|
|
1250
|
+
: undefined,
|
|
1251
|
+
current_position_seniority_level: isNonEmptyArray(input.seniorities)
|
|
1252
|
+
? (input.seniorities as string[]).map((v) => ({ value: v }))
|
|
1253
|
+
: undefined,
|
|
1254
|
+
person_locations: isNonEmptyArray(input.locations)
|
|
1255
|
+
? (input.locations as string[]).map((v) => ({ value: v }))
|
|
1256
|
+
: undefined,
|
|
1257
|
+
limit: Math.min((input.limit as number | undefined) ?? 10, 100),
|
|
1258
|
+
},
|
|
1259
|
+
}),
|
|
1260
|
+
hasResult: (data) => {
|
|
1261
|
+
const d = data as Record<string, unknown>
|
|
1262
|
+
return isNonEmptyArray(d.people)
|
|
1263
|
+
},
|
|
1264
|
+
},
|
|
1265
|
+
{
|
|
1266
|
+
id: 'findymail-search-employees',
|
|
1267
|
+
endpoint: '/findymail/search/employees',
|
|
1268
|
+
method: 'POST',
|
|
1269
|
+
priority: 10,
|
|
1270
|
+
// Findymail employees search requires a domain and at least one job title.
|
|
1271
|
+
isApplicable: (input) =>
|
|
1272
|
+
isNonEmptyArray(input.company_domains) && isNonEmptyArray(input.job_titles),
|
|
1273
|
+
mapParams: (input) => ({
|
|
1274
|
+
body: {
|
|
1275
|
+
website: (input.company_domains as string[])[0],
|
|
1276
|
+
job_titles: input.job_titles,
|
|
1277
|
+
// count is capped at 5 by the Findymail API; don't exceed it
|
|
1278
|
+
count: Math.min((input.limit as number | undefined) ?? 5, 5),
|
|
1279
|
+
},
|
|
1280
|
+
}),
|
|
1281
|
+
hasResult: (data) => {
|
|
1282
|
+
// Findymail returns an array directly or wraps in a contacts/results key
|
|
1283
|
+
if (Array.isArray(data)) return (data as unknown[]).length > 0
|
|
1284
|
+
const d = data as Record<string, unknown>
|
|
1285
|
+
return hasAnyKey(d, ['contacts', 'results', 'people'])
|
|
1286
|
+
},
|
|
1287
|
+
},
|
|
1288
|
+
]
|
|
1289
|
+
|
|
1290
|
+
// ---------------------------------------------------------------------------
|
|
1291
|
+
// find_email (waterfall)
|
|
1292
|
+
// ---------------------------------------------------------------------------
|
|
1293
|
+
|
|
1294
|
+
const findEmailProviders: ProviderEntry[] = [
|
|
1295
|
+
{
|
|
1296
|
+
id: 'findymail',
|
|
1297
|
+
endpoint: '/findymail/search/name',
|
|
1298
|
+
method: 'POST',
|
|
1299
|
+
priority: 1,
|
|
1300
|
+
mapParams: (input) => ({
|
|
1301
|
+
body: {
|
|
1302
|
+
name: `${input.first_name} ${input.last_name}`,
|
|
1303
|
+
domain: input.domain,
|
|
1304
|
+
},
|
|
1305
|
+
}),
|
|
1306
|
+
hasResult: (data) => {
|
|
1307
|
+
const d = data as Record<string, unknown>
|
|
1308
|
+
return typeof d.email === 'string' && d.email.includes('@')
|
|
1309
|
+
},
|
|
1310
|
+
},
|
|
1311
|
+
{
|
|
1312
|
+
id: 'icypeas',
|
|
1313
|
+
endpoint: '/icypeas/email-search',
|
|
1314
|
+
method: 'POST',
|
|
1315
|
+
priority: 2,
|
|
1316
|
+
mapParams: (input) => ({
|
|
1317
|
+
body: {
|
|
1318
|
+
firstname: input.first_name,
|
|
1319
|
+
lastname: input.last_name,
|
|
1320
|
+
domainOrCompany: input.domain || input.company_name,
|
|
1321
|
+
},
|
|
1322
|
+
}),
|
|
1323
|
+
hasResult: (data) => {
|
|
1324
|
+
const d = data as Record<string, unknown>
|
|
1325
|
+
return (
|
|
1326
|
+
(typeof d.email === 'string' && d.email.includes('@')) ||
|
|
1327
|
+
isNonEmptyArray(d.emails)
|
|
1328
|
+
)
|
|
1329
|
+
},
|
|
1330
|
+
},
|
|
1331
|
+
{
|
|
1332
|
+
id: 'limadata-work-email',
|
|
1333
|
+
endpoint: '/coldiq/find/work-email',
|
|
1334
|
+
method: 'POST',
|
|
1335
|
+
priority: 3,
|
|
1336
|
+
mapParams: (input) => {
|
|
1337
|
+
const fullName = [input.first_name, input.last_name].filter(Boolean).join(' ')
|
|
1338
|
+
return {
|
|
1339
|
+
body: {
|
|
1340
|
+
full_name: fullName,
|
|
1341
|
+
company_domain: input.domain,
|
|
1342
|
+
},
|
|
1343
|
+
}
|
|
1344
|
+
},
|
|
1345
|
+
hasResult: (data) => {
|
|
1346
|
+
const d = data as Record<string, unknown>
|
|
1347
|
+
// Upstream is a thin proxy — match common B2B email shapes.
|
|
1348
|
+
if (typeof d.email === 'string' && d.email.includes('@')) return true
|
|
1349
|
+
if (Array.isArray(d.emails) && typeof d.emails[0] === 'string' && (d.emails[0] as string).includes('@')) return true
|
|
1350
|
+
const nested = d.data as Record<string, unknown> | undefined
|
|
1351
|
+
if (nested && typeof nested.email === 'string' && (nested.email as string).includes('@')) return true
|
|
1352
|
+
return false
|
|
1353
|
+
},
|
|
1354
|
+
isApplicable: (input) =>
|
|
1355
|
+
typeof input.first_name === 'string' && (input.first_name as string).length > 0 &&
|
|
1356
|
+
typeof input.last_name === 'string' && (input.last_name as string).length > 0 &&
|
|
1357
|
+
typeof input.domain === 'string' && (input.domain as string).length > 0,
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
id: 'prospeo',
|
|
1361
|
+
endpoint: '/prospeo/enrich-person',
|
|
1362
|
+
method: 'POST',
|
|
1363
|
+
priority: 4,
|
|
1364
|
+
mapParams: (input) => {
|
|
1365
|
+
if (input.linkedin_url) {
|
|
1366
|
+
return { body: { data: { linkedin_url: input.linkedin_url } } }
|
|
1367
|
+
}
|
|
1368
|
+
return {
|
|
1369
|
+
body: {
|
|
1370
|
+
data: {
|
|
1371
|
+
first_name: input.first_name,
|
|
1372
|
+
last_name: input.last_name,
|
|
1373
|
+
company_name: input.company_name || input.domain,
|
|
1374
|
+
},
|
|
1375
|
+
},
|
|
1376
|
+
}
|
|
1377
|
+
},
|
|
1378
|
+
hasResult: (data) => {
|
|
1379
|
+
const d = data as Record<string, unknown>
|
|
1380
|
+
const person = d.person as Record<string, unknown> | undefined
|
|
1381
|
+
const emailObj = person?.email as Record<string, unknown> | undefined
|
|
1382
|
+
return typeof emailObj?.email === 'string' && (emailObj.email as string).includes('@')
|
|
1383
|
+
},
|
|
1384
|
+
},
|
|
1385
|
+
{
|
|
1386
|
+
id: 'blitzapi',
|
|
1387
|
+
endpoint: '/blitzapi/enrichment/find-work-email',
|
|
1388
|
+
method: 'POST',
|
|
1389
|
+
priority: 5,
|
|
1390
|
+
mapParams: (input) => ({
|
|
1391
|
+
body: { person_linkedin_url: input.linkedin_url as string },
|
|
1392
|
+
}),
|
|
1393
|
+
hasResult: (data) => {
|
|
1394
|
+
const d = data as Record<string, unknown>
|
|
1395
|
+
return typeof d.email === 'string' && d.email.includes('@')
|
|
1396
|
+
},
|
|
1397
|
+
isApplicable: (input) => typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0,
|
|
1398
|
+
},
|
|
1399
|
+
{
|
|
1400
|
+
id: 'fullenrich',
|
|
1401
|
+
endpoint: '/fullenrich/contact/enrich/bulk',
|
|
1402
|
+
method: 'POST',
|
|
1403
|
+
priority: 6,
|
|
1404
|
+
mapParams: (input) => ({
|
|
1405
|
+
body: {
|
|
1406
|
+
name: 'mcp-enrich',
|
|
1407
|
+
data: [{
|
|
1408
|
+
first_name: input.first_name,
|
|
1409
|
+
last_name: input.last_name,
|
|
1410
|
+
domain: input.domain,
|
|
1411
|
+
company_name: input.company_name,
|
|
1412
|
+
...(input.linkedin_url ? { linkedin_url: input.linkedin_url } : {}),
|
|
1413
|
+
enrich_fields: ['contact.emails'],
|
|
1414
|
+
}],
|
|
1415
|
+
},
|
|
1416
|
+
}),
|
|
1417
|
+
hasResult: (data) => {
|
|
1418
|
+
const d = data as Record<string, unknown>
|
|
1419
|
+
const items = d.data as Array<Record<string, unknown>> | undefined
|
|
1420
|
+
if (!Array.isArray(items) || items.length === 0) return false
|
|
1421
|
+
const emails = items[0]?.emails as unknown[] | undefined
|
|
1422
|
+
return Array.isArray(emails) && emails.length > 0
|
|
1423
|
+
},
|
|
1424
|
+
async: {
|
|
1425
|
+
extractId: (response) => {
|
|
1426
|
+
const d = response as Record<string, unknown>
|
|
1427
|
+
return d.enrichment_id as string
|
|
1428
|
+
},
|
|
1429
|
+
pollEndpoint: (id) => `/fullenrich/contact/enrich/bulk/${id}`,
|
|
1430
|
+
pollIntervalMs: 5000,
|
|
1431
|
+
timeoutMs: 60_000,
|
|
1432
|
+
isComplete: (data) => {
|
|
1433
|
+
const d = data as Record<string, unknown>
|
|
1434
|
+
const status = d.status as string | undefined
|
|
1435
|
+
return status === 'DONE' || status === 'FAILED'
|
|
1436
|
+
},
|
|
1437
|
+
},
|
|
1438
|
+
},
|
|
1439
|
+
{
|
|
1440
|
+
id: 'limadata-work-email-linkedin',
|
|
1441
|
+
endpoint: '/coldiq/find/work-email-linkedin',
|
|
1442
|
+
method: 'POST',
|
|
1443
|
+
priority: 7,
|
|
1444
|
+
mapParams: (input) => ({
|
|
1445
|
+
body: { linkedin_url: input.linkedin_url as string },
|
|
1446
|
+
}),
|
|
1447
|
+
hasResult: (data) => {
|
|
1448
|
+
const d = data as Record<string, unknown>
|
|
1449
|
+
if (typeof d.email === 'string' && d.email.includes('@')) return true
|
|
1450
|
+
if (Array.isArray(d.emails) && typeof d.emails[0] === 'string' && (d.emails[0] as string).includes('@')) return true
|
|
1451
|
+
const nested = d.data as Record<string, unknown> | undefined
|
|
1452
|
+
if (nested && typeof nested.email === 'string' && (nested.email as string).includes('@')) return true
|
|
1453
|
+
return false
|
|
1454
|
+
},
|
|
1455
|
+
isApplicable: (input) => typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0,
|
|
1456
|
+
},
|
|
1457
|
+
{
|
|
1458
|
+
id: 'linkupapi',
|
|
1459
|
+
endpoint: '/linkupapi/data/mail/finder',
|
|
1460
|
+
method: 'POST',
|
|
1461
|
+
priority: 8,
|
|
1462
|
+
mapParams: (input) => ({
|
|
1463
|
+
body: {
|
|
1464
|
+
first_name: input.first_name,
|
|
1465
|
+
last_name: input.last_name,
|
|
1466
|
+
company_name: input.company_name || input.domain,
|
|
1467
|
+
},
|
|
1468
|
+
}),
|
|
1469
|
+
hasResult: (data) => {
|
|
1470
|
+
const d = data as Record<string, unknown>
|
|
1471
|
+
return typeof d.email === 'string' && d.email.includes('@')
|
|
1472
|
+
},
|
|
1473
|
+
},
|
|
1474
|
+
]
|
|
1475
|
+
|
|
1476
|
+
// ---------------------------------------------------------------------------
|
|
1477
|
+
// verify_email
|
|
1478
|
+
// ---------------------------------------------------------------------------
|
|
1479
|
+
|
|
1480
|
+
const INSTANTLY_TERMINAL_STATUSES = new Set(['valid', 'invalid', 'unknown', 'risky', 'catch_all', 'disposable'])
|
|
1481
|
+
|
|
1482
|
+
const verifyEmailProviders: ProviderEntry[] = [
|
|
1483
|
+
{
|
|
1484
|
+
id: 'findymail',
|
|
1485
|
+
endpoint: '/findymail/verify',
|
|
1486
|
+
method: 'POST',
|
|
1487
|
+
priority: 1,
|
|
1488
|
+
mapParams: (input) => ({ body: { email: input.email } }),
|
|
1489
|
+
hasResult: (data) => {
|
|
1490
|
+
const d = data as Record<string, unknown>
|
|
1491
|
+
// Findymail verify returns { email, verified: boolean, provider }
|
|
1492
|
+
return d.verified !== undefined || d.status !== undefined || d.result !== undefined || d.valid !== undefined
|
|
1493
|
+
},
|
|
1494
|
+
},
|
|
1495
|
+
{
|
|
1496
|
+
id: 'icypeas',
|
|
1497
|
+
endpoint: '/icypeas/email-verification',
|
|
1498
|
+
method: 'POST',
|
|
1499
|
+
priority: 2,
|
|
1500
|
+
mapParams: (input) => ({ body: { email: input.email } }),
|
|
1501
|
+
hasResult: (data) => {
|
|
1502
|
+
const d = data as Record<string, unknown>
|
|
1503
|
+
// IcyPeas verify returns { success: boolean, item: { status } }
|
|
1504
|
+
return d.success !== undefined || d.status !== undefined || d.result !== undefined || d.valid !== undefined
|
|
1505
|
+
},
|
|
1506
|
+
},
|
|
1507
|
+
{
|
|
1508
|
+
id: 'instantly',
|
|
1509
|
+
endpoint: '/instantly/email-verification',
|
|
1510
|
+
method: 'POST',
|
|
1511
|
+
priority: 3,
|
|
1512
|
+
mapParams: (input) => ({ body: { email: input.email } }),
|
|
1513
|
+
hasResult: (data) => {
|
|
1514
|
+
const d = data as Record<string, unknown>
|
|
1515
|
+
const status = d.status as string | undefined
|
|
1516
|
+
return typeof status === 'string' && INSTANTLY_TERMINAL_STATUSES.has(status)
|
|
1517
|
+
},
|
|
1518
|
+
async: {
|
|
1519
|
+
extractId: (response) => {
|
|
1520
|
+
const r = response as Record<string, unknown>
|
|
1521
|
+
if (typeof r.email === 'string' && r.email.length > 0) return r.email
|
|
1522
|
+
throw new Error('Instantly verification response has no email field')
|
|
1523
|
+
},
|
|
1524
|
+
pollEndpoint: (email) => `/instantly/email-verification/${encodeURIComponent(email)}`,
|
|
1525
|
+
pollIntervalMs: 3000,
|
|
1526
|
+
timeoutMs: 30_000,
|
|
1527
|
+
isComplete: (data) => {
|
|
1528
|
+
const d = data as Record<string, unknown>
|
|
1529
|
+
const status = d.status as string | undefined
|
|
1530
|
+
return typeof status === 'string' && INSTANTLY_TERMINAL_STATUSES.has(status)
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1533
|
+
},
|
|
1534
|
+
{
|
|
1535
|
+
id: 'linkupapi-validate',
|
|
1536
|
+
endpoint: '/linkupapi/data/mail/validate',
|
|
1537
|
+
method: 'POST',
|
|
1538
|
+
priority: 4,
|
|
1539
|
+
mapParams: (input) => ({ body: { email: input.email } }),
|
|
1540
|
+
hasResult: (data) => {
|
|
1541
|
+
const d = data as Record<string, unknown>
|
|
1542
|
+
return d.verified !== undefined || d.status !== undefined || d.result !== undefined || d.valid !== undefined
|
|
1543
|
+
},
|
|
1544
|
+
},
|
|
1545
|
+
]
|
|
1546
|
+
|
|
1547
|
+
// ---------------------------------------------------------------------------
|
|
1548
|
+
// find_phone
|
|
1549
|
+
// ---------------------------------------------------------------------------
|
|
1550
|
+
|
|
1551
|
+
const findPhoneProviders: ProviderEntry[] = [
|
|
1552
|
+
{
|
|
1553
|
+
id: 'findymail',
|
|
1554
|
+
endpoint: '/findymail/search/phone',
|
|
1555
|
+
method: 'POST',
|
|
1556
|
+
priority: 1,
|
|
1557
|
+
isApplicable: (input) => typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0,
|
|
1558
|
+
mapParams: (input) => ({ body: { linkedin_url: input.linkedin_url } }),
|
|
1559
|
+
hasResult: (data) => {
|
|
1560
|
+
const d = data as Record<string, unknown>
|
|
1561
|
+
return typeof d.phone === 'string' || isNonEmptyArray(d.phones)
|
|
1562
|
+
},
|
|
1563
|
+
},
|
|
1564
|
+
{
|
|
1565
|
+
id: 'limadata',
|
|
1566
|
+
endpoint: '/coldiq/find/phone',
|
|
1567
|
+
method: 'POST',
|
|
1568
|
+
priority: 2,
|
|
1569
|
+
isApplicable: (input) => {
|
|
1570
|
+
if (typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0) return true
|
|
1571
|
+
const hasName = typeof input.first_name === 'string' && (input.first_name as string).length > 0 &&
|
|
1572
|
+
typeof input.last_name === 'string' && (input.last_name as string).length > 0
|
|
1573
|
+
const hasCompany = (typeof input.company_domain === 'string' && (input.company_domain as string).length > 0) ||
|
|
1574
|
+
(typeof input.company_name === 'string' && (input.company_name as string).length > 0)
|
|
1575
|
+
return hasName && hasCompany
|
|
1576
|
+
},
|
|
1577
|
+
mapParams: (input) => {
|
|
1578
|
+
const firstName = input.first_name as string | undefined
|
|
1579
|
+
const lastName = input.last_name as string | undefined
|
|
1580
|
+
const fullName = [firstName, lastName].filter(Boolean).join(' ') || undefined
|
|
1581
|
+
return {
|
|
1582
|
+
body: {
|
|
1583
|
+
...(input.linkedin_url ? { linkedin_url: input.linkedin_url } : {}),
|
|
1584
|
+
...(fullName ? { name: fullName } : {}),
|
|
1585
|
+
...(input.company_name ? { company_name: input.company_name } : {}),
|
|
1586
|
+
...(input.company_domain ? { company_domain: input.company_domain } : {}),
|
|
1587
|
+
},
|
|
1588
|
+
}
|
|
1589
|
+
},
|
|
1590
|
+
hasResult: (data) => {
|
|
1591
|
+
const d = data as Record<string, unknown>
|
|
1592
|
+
if (typeof d.phone === 'string' && (d.phone as string).length > 0) return true
|
|
1593
|
+
if (isNonEmptyArray(d.phones)) return true
|
|
1594
|
+
if (isNonEmptyArray(d.phone_numbers)) return true
|
|
1595
|
+
const nested = d.data as Record<string, unknown> | undefined
|
|
1596
|
+
if (nested && typeof nested.phone === 'string' && (nested.phone as string).length > 0) return true
|
|
1597
|
+
if (nested && isNonEmptyArray(nested.phones)) return true
|
|
1598
|
+
if (nested && isNonEmptyArray(nested.phone_numbers)) return true
|
|
1599
|
+
return false
|
|
1600
|
+
},
|
|
1601
|
+
},
|
|
1602
|
+
{
|
|
1603
|
+
id: 'ai-ark',
|
|
1604
|
+
endpoint: '/ai-ark/people/mobile-phone-finder',
|
|
1605
|
+
method: 'POST',
|
|
1606
|
+
priority: 3,
|
|
1607
|
+
isApplicable: (input) => {
|
|
1608
|
+
if (typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0) return true
|
|
1609
|
+
const hasName = typeof input.first_name === 'string' && (input.first_name as string).length > 0 &&
|
|
1610
|
+
typeof input.last_name === 'string' && (input.last_name as string).length > 0
|
|
1611
|
+
const hasDomain = typeof input.company_domain === 'string' && (input.company_domain as string).length > 0
|
|
1612
|
+
return hasName && hasDomain
|
|
1613
|
+
},
|
|
1614
|
+
mapParams: (input) => {
|
|
1615
|
+
const firstName = input.first_name as string | undefined
|
|
1616
|
+
const lastName = input.last_name as string | undefined
|
|
1617
|
+
const fullName = [firstName, lastName].filter(Boolean).join(' ') || undefined
|
|
1618
|
+
return {
|
|
1619
|
+
body: {
|
|
1620
|
+
...(input.linkedin_url ? { linkedin: input.linkedin_url } : {}),
|
|
1621
|
+
...(input.company_domain ? { domain: input.company_domain } : {}),
|
|
1622
|
+
...(fullName ? { name: fullName } : {}),
|
|
1623
|
+
},
|
|
1624
|
+
}
|
|
1625
|
+
},
|
|
1626
|
+
hasResult: (data) => {
|
|
1627
|
+
const d = data as Record<string, unknown>
|
|
1628
|
+
return Array.isArray(d.data) &&
|
|
1629
|
+
(d.data as unknown[]).some(
|
|
1630
|
+
(arr) => Array.isArray(arr) && (arr as unknown[]).length > 0 && typeof (arr as unknown[])[0] === 'string',
|
|
1631
|
+
)
|
|
1632
|
+
},
|
|
1633
|
+
},
|
|
1634
|
+
]
|
|
1635
|
+
|
|
1636
|
+
// ---------------------------------------------------------------------------
|
|
1637
|
+
// enrich_company
|
|
1638
|
+
// ---------------------------------------------------------------------------
|
|
1639
|
+
|
|
1640
|
+
const enrichCompanyProviders: ProviderEntry[] = [
|
|
1641
|
+
{
|
|
1642
|
+
id: 'companyenrich',
|
|
1643
|
+
endpoint: '/companyenrich/companies/enrich',
|
|
1644
|
+
method: 'GET',
|
|
1645
|
+
priority: 1,
|
|
1646
|
+
isApplicable: (input) => !!input.domain,
|
|
1647
|
+
mapParams: (input) => ({
|
|
1648
|
+
queryParams: { domain: input.domain as string },
|
|
1649
|
+
}),
|
|
1650
|
+
hasResult: (data) => {
|
|
1651
|
+
const d = data as Record<string, unknown>
|
|
1652
|
+
return hasAnyKey(d, ['name', 'domain', 'company'])
|
|
1653
|
+
},
|
|
1654
|
+
},
|
|
1655
|
+
{
|
|
1656
|
+
id: 'apollo',
|
|
1657
|
+
endpoint: '/apollo/organizations/enrich',
|
|
1658
|
+
method: 'POST',
|
|
1659
|
+
priority: 3,
|
|
1660
|
+
isApplicable: (input) => !!input.domain,
|
|
1661
|
+
mapParams: (input) => ({ body: { domain: input.domain } }),
|
|
1662
|
+
hasResult: (data) => {
|
|
1663
|
+
const d = data as Record<string, unknown>
|
|
1664
|
+
return hasAnyKey(d, ['organization', 'name', 'domain'])
|
|
1665
|
+
},
|
|
1666
|
+
},
|
|
1667
|
+
{
|
|
1668
|
+
id: 'pdl',
|
|
1669
|
+
endpoint: '/pdl/company/enrich',
|
|
1670
|
+
method: 'GET',
|
|
1671
|
+
priority: 2,
|
|
1672
|
+
mapParams: (input) => ({
|
|
1673
|
+
queryParams: {
|
|
1674
|
+
...(input.domain ? { website: input.domain as string } : {}),
|
|
1675
|
+
...(input.linkedin_url ? { profile: input.linkedin_url as string } : {}),
|
|
1676
|
+
...(input.name ? { name: input.name as string } : {}),
|
|
1677
|
+
},
|
|
1678
|
+
}),
|
|
1679
|
+
hasResult: (data) => {
|
|
1680
|
+
const d = data as Record<string, unknown>
|
|
1681
|
+
return hasAnyKey(d, ['name', 'website', 'display_name'])
|
|
1682
|
+
},
|
|
1683
|
+
},
|
|
1684
|
+
{
|
|
1685
|
+
id: 'findymail',
|
|
1686
|
+
endpoint: '/findymail/search/company',
|
|
1687
|
+
method: 'POST',
|
|
1688
|
+
priority: 9,
|
|
1689
|
+
mapParams: (input) => ({
|
|
1690
|
+
body: {
|
|
1691
|
+
...(input.domain ? { domain: input.domain } : {}),
|
|
1692
|
+
...(input.linkedin_url ? { linkedin_url: input.linkedin_url } : {}),
|
|
1693
|
+
...(input.name ? { name: input.name } : {}),
|
|
1694
|
+
},
|
|
1695
|
+
}),
|
|
1696
|
+
hasResult: (data) => {
|
|
1697
|
+
const d = data as Record<string, unknown>
|
|
1698
|
+
return hasAnyKey(d, ['name', 'domain'])
|
|
1699
|
+
},
|
|
1700
|
+
},
|
|
1701
|
+
{
|
|
1702
|
+
id: 'wiza',
|
|
1703
|
+
endpoint: '/wiza/company-enrichments',
|
|
1704
|
+
method: 'POST',
|
|
1705
|
+
priority: 8,
|
|
1706
|
+
isApplicable: (input) => !!(input.domain || input.name),
|
|
1707
|
+
mapParams: (input) => ({
|
|
1708
|
+
body: {
|
|
1709
|
+
...(input.domain ? { company_domain: input.domain } : {}),
|
|
1710
|
+
...(input.name ? { company_name: input.name } : {}),
|
|
1711
|
+
},
|
|
1712
|
+
}),
|
|
1713
|
+
hasResult: (data) => {
|
|
1714
|
+
const d = data as Record<string, unknown>
|
|
1715
|
+
const inner = d.data as Record<string, unknown> | undefined
|
|
1716
|
+
return !!(inner?.domain || inner?.industry || inner?.description)
|
|
1717
|
+
},
|
|
1718
|
+
},
|
|
1719
|
+
{
|
|
1720
|
+
id: 'limadata',
|
|
1721
|
+
endpoint: '/coldiq/enrich/company',
|
|
1722
|
+
method: 'POST',
|
|
1723
|
+
priority: 4,
|
|
1724
|
+
isApplicable: (input) => !!(input.domain || input.linkedin_url),
|
|
1725
|
+
mapParams: (input) => ({
|
|
1726
|
+
body: {
|
|
1727
|
+
...(input.domain ? { domain: input.domain } : {}),
|
|
1728
|
+
...(input.linkedin_url ? { linkedin_url: input.linkedin_url } : {}),
|
|
1729
|
+
},
|
|
1730
|
+
}),
|
|
1731
|
+
hasResult: (data) => {
|
|
1732
|
+
const d = data as Record<string, unknown>
|
|
1733
|
+
return hasAnyKey(d, ['name', 'domain', 'website', 'data'])
|
|
1734
|
+
},
|
|
1735
|
+
},
|
|
1736
|
+
{
|
|
1737
|
+
id: 'prospeo',
|
|
1738
|
+
endpoint: '/prospeo/enrich-company',
|
|
1739
|
+
method: 'POST',
|
|
1740
|
+
priority: 5,
|
|
1741
|
+
mapParams: (input) => ({
|
|
1742
|
+
body: {
|
|
1743
|
+
data: {
|
|
1744
|
+
...(input.domain ? { company_website: input.domain } : {}),
|
|
1745
|
+
...(input.linkedin_url ? { company_linkedin_url: input.linkedin_url } : {}),
|
|
1746
|
+
...(input.name ? { company_name: input.name } : {}),
|
|
1747
|
+
},
|
|
1748
|
+
},
|
|
1749
|
+
}),
|
|
1750
|
+
hasResult: (data) => {
|
|
1751
|
+
const d = data as Record<string, unknown>
|
|
1752
|
+
return !d.error && !!d.response
|
|
1753
|
+
},
|
|
1754
|
+
},
|
|
1755
|
+
{
|
|
1756
|
+
id: 'companyenrich_props',
|
|
1757
|
+
endpoint: '/companyenrich/companies/enrich',
|
|
1758
|
+
method: 'POST',
|
|
1759
|
+
priority: 6,
|
|
1760
|
+
isApplicable: (input) => !!(input.name || input.linkedin_url),
|
|
1761
|
+
mapParams: (input) => ({
|
|
1762
|
+
body: {
|
|
1763
|
+
...(input.name ? { name: input.name } : {}),
|
|
1764
|
+
...(input.linkedin_url ? { linkedinUrl: input.linkedin_url } : {}),
|
|
1765
|
+
},
|
|
1766
|
+
}),
|
|
1767
|
+
hasResult: (data) => {
|
|
1768
|
+
const d = data as Record<string, unknown>
|
|
1769
|
+
return hasAnyKey(d, ['name', 'domain', 'company'])
|
|
1770
|
+
},
|
|
1771
|
+
},
|
|
1772
|
+
{
|
|
1773
|
+
id: 'blitzapi',
|
|
1774
|
+
endpoint: '/blitzapi/enrichment/company',
|
|
1775
|
+
method: 'POST',
|
|
1776
|
+
priority: 7,
|
|
1777
|
+
isApplicable: (input) => !!input.linkedin_url,
|
|
1778
|
+
mapParams: (input) => ({
|
|
1779
|
+
body: { company_linkedin_url: input.linkedin_url },
|
|
1780
|
+
}),
|
|
1781
|
+
hasResult: (data) => {
|
|
1782
|
+
const d = data as Record<string, unknown>
|
|
1783
|
+
return hasAnyKey(d, ['name', 'domain', 'website', 'id'])
|
|
1784
|
+
},
|
|
1785
|
+
},
|
|
1786
|
+
{
|
|
1787
|
+
id: 'icypeas',
|
|
1788
|
+
endpoint: '/icypeas/scrape/company',
|
|
1789
|
+
method: 'POST',
|
|
1790
|
+
priority: 10,
|
|
1791
|
+
isApplicable: (input) => !!input.linkedin_url,
|
|
1792
|
+
mapParams: (input) => ({
|
|
1793
|
+
body: { url: input.linkedin_url },
|
|
1794
|
+
}),
|
|
1795
|
+
hasResult: (data) => {
|
|
1796
|
+
const d = data as Record<string, unknown>
|
|
1797
|
+
return hasAnyKey(d, ['name', 'domain', 'website', 'universal_name'])
|
|
1798
|
+
},
|
|
1799
|
+
},
|
|
1800
|
+
{
|
|
1801
|
+
id: 'builtwith',
|
|
1802
|
+
endpoint: '/builtwith/domain',
|
|
1803
|
+
method: 'POST',
|
|
1804
|
+
priority: 11,
|
|
1805
|
+
isApplicable: (input) => !!input.domain,
|
|
1806
|
+
mapParams: (input) => ({ body: { domain: input.domain } }),
|
|
1807
|
+
hasResult: (data) => {
|
|
1808
|
+
const d = data as Record<string, unknown>
|
|
1809
|
+
const results = d.Results as Array<Record<string, unknown>> | undefined
|
|
1810
|
+
if (!Array.isArray(results) || results.length === 0) return false
|
|
1811
|
+
const inner = results[0]?.Result as Record<string, unknown> | undefined
|
|
1812
|
+
const paths = inner?.Paths as unknown[] | undefined
|
|
1813
|
+
return Array.isArray(paths) && paths.length > 0
|
|
1814
|
+
},
|
|
1815
|
+
},
|
|
1816
|
+
{
|
|
1817
|
+
id: 'openmart',
|
|
1818
|
+
endpoint: '/openmart/enrich_company',
|
|
1819
|
+
method: 'POST',
|
|
1820
|
+
priority: 12,
|
|
1821
|
+
isApplicable: (input) => !!input.domain,
|
|
1822
|
+
mapParams: (input) => ({ body: { website: input.domain, limit: 1 } }),
|
|
1823
|
+
hasResult: (data) => {
|
|
1824
|
+
if (isNonEmptyArray(data)) return true
|
|
1825
|
+
const d = data as Record<string, unknown>
|
|
1826
|
+
return isNonEmptyArray(d.data) || isNonEmptyArray(d.results)
|
|
1827
|
+
},
|
|
1828
|
+
},
|
|
1829
|
+
{
|
|
1830
|
+
id: 'linkupapi-by-domain',
|
|
1831
|
+
endpoint: '/linkupapi/data/company/info-by-domain',
|
|
1832
|
+
method: 'POST',
|
|
1833
|
+
priority: 13,
|
|
1834
|
+
isApplicable: (input) => !!input.domain,
|
|
1835
|
+
mapParams: (input) => ({ body: { domain: input.domain } }),
|
|
1836
|
+
hasResult: (data) => hasAnyKey(data, ['name', 'domain', 'website', 'company']),
|
|
1837
|
+
},
|
|
1838
|
+
{
|
|
1839
|
+
id: 'linkupapi-by-url',
|
|
1840
|
+
endpoint: '/linkupapi/data/company/info',
|
|
1841
|
+
method: 'POST',
|
|
1842
|
+
priority: 14,
|
|
1843
|
+
isApplicable: (input) => !!input.linkedin_url,
|
|
1844
|
+
mapParams: (input) => ({ body: { company_url: input.linkedin_url } }),
|
|
1845
|
+
hasResult: (data) => hasAnyKey(data, ['name', 'domain', 'website', 'company']),
|
|
1846
|
+
},
|
|
1847
|
+
]
|
|
1848
|
+
|
|
1849
|
+
// ---------------------------------------------------------------------------
|
|
1850
|
+
// search_web
|
|
1851
|
+
// ---------------------------------------------------------------------------
|
|
1852
|
+
|
|
1853
|
+
const searchWebProviders: ProviderEntry[] = [
|
|
1854
|
+
{
|
|
1855
|
+
id: 'serper',
|
|
1856
|
+
endpoint: '/serper/search',
|
|
1857
|
+
method: 'POST',
|
|
1858
|
+
priority: 1,
|
|
1859
|
+
mapParams: (input) => ({
|
|
1860
|
+
body: {
|
|
1861
|
+
q: input.query,
|
|
1862
|
+
num: input.num_results ?? 10,
|
|
1863
|
+
gl: input.country,
|
|
1864
|
+
},
|
|
1865
|
+
}),
|
|
1866
|
+
hasResult: (data) => {
|
|
1867
|
+
const d = data as Record<string, unknown>
|
|
1868
|
+
return isNonEmptyArray(d.organic) || isNonEmptyArray(d.results)
|
|
1869
|
+
},
|
|
1870
|
+
},
|
|
1871
|
+
{
|
|
1872
|
+
id: 'exa',
|
|
1873
|
+
endpoint: '/exa/search',
|
|
1874
|
+
method: 'POST',
|
|
1875
|
+
priority: 3,
|
|
1876
|
+
mapParams: (input) => ({
|
|
1877
|
+
body: {
|
|
1878
|
+
query: input.query,
|
|
1879
|
+
numResults: input.num_results ?? 10,
|
|
1880
|
+
type: input.search_type === 'neural' ? 'neural' : 'auto',
|
|
1881
|
+
},
|
|
1882
|
+
}),
|
|
1883
|
+
hasResult: (data) => {
|
|
1884
|
+
const d = data as Record<string, unknown>
|
|
1885
|
+
return isNonEmptyArray(d.results)
|
|
1886
|
+
},
|
|
1887
|
+
},
|
|
1888
|
+
{
|
|
1889
|
+
id: 'limadata',
|
|
1890
|
+
endpoint: '/coldiq/search/web',
|
|
1891
|
+
method: 'POST',
|
|
1892
|
+
priority: 2,
|
|
1893
|
+
mapParams: (input) => ({
|
|
1894
|
+
body: { query: input.query },
|
|
1895
|
+
}),
|
|
1896
|
+
hasResult: (data) => {
|
|
1897
|
+
const d = data as Record<string, unknown>
|
|
1898
|
+
return isNonEmptyArray(d.organic) || isNonEmptyArray(d.results)
|
|
1899
|
+
},
|
|
1900
|
+
},
|
|
1901
|
+
{
|
|
1902
|
+
id: 'jina',
|
|
1903
|
+
endpoint: '/jina/search',
|
|
1904
|
+
method: 'POST',
|
|
1905
|
+
priority: 4,
|
|
1906
|
+
mapParams: (input) => ({
|
|
1907
|
+
body: {
|
|
1908
|
+
q: input.query,
|
|
1909
|
+
// Jina caps at 20 results per call vs the tool's 100 max
|
|
1910
|
+
count: Math.min((input.num_results as number | undefined) ?? 10, 20),
|
|
1911
|
+
gl: input.country,
|
|
1912
|
+
},
|
|
1913
|
+
}),
|
|
1914
|
+
hasResult: (data) => {
|
|
1915
|
+
const d = data as Record<string, unknown>
|
|
1916
|
+
return isNonEmptyArray(d.data) || isNonEmptyArray(d.results)
|
|
1917
|
+
},
|
|
1918
|
+
},
|
|
1919
|
+
]
|
|
1920
|
+
|
|
1921
|
+
// ---------------------------------------------------------------------------
|
|
1922
|
+
// search_jobs
|
|
1923
|
+
// ---------------------------------------------------------------------------
|
|
1924
|
+
|
|
1925
|
+
// Keys in the MCP schema that only LinkedIn Jobs API can serve.
|
|
1926
|
+
// If any of these are set, Career Site Jobs is skipped.
|
|
1927
|
+
const _LINKEDIN_ONLY_KEYS = ['seniority_levels', 'industries', 'organization_slugs', 'exclude_organization_slugs', 'min_employees', 'max_employees', 'easy_apply_only', 'exclude_easy_apply']
|
|
1928
|
+
|
|
1929
|
+
// Keys in the MCP schema that only Career Site Jobs can serve.
|
|
1930
|
+
// If any of these are set, LinkedIn Jobs API is skipped.
|
|
1931
|
+
const _CAREER_SITE_ONLY_KEYS = ['ats_slugs', 'exclude_ats_slugs', 'company_domains', 'exclude_company_domains']
|
|
1932
|
+
|
|
1933
|
+
function _hasJobFilter(input: Record<string, unknown>, keys: string[]): boolean {
|
|
1934
|
+
return keys.some((k) => {
|
|
1935
|
+
const v = input[k]
|
|
1936
|
+
return !!v && (!Array.isArray(v) || v.length > 0)
|
|
1937
|
+
})
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const _jobsSharedAsync = {
|
|
1941
|
+
pollIntervalMs: 5_000,
|
|
1942
|
+
timeoutMs: 300_000,
|
|
1943
|
+
extractId: (data: unknown) => {
|
|
1944
|
+
const jobId = (data as { jobId?: number | string }).jobId
|
|
1945
|
+
if (jobId === undefined || jobId === null || jobId === '') {
|
|
1946
|
+
throw new Error('Jobs response has no jobId')
|
|
1947
|
+
}
|
|
1948
|
+
return String(jobId)
|
|
1949
|
+
},
|
|
1950
|
+
isComplete: (data: unknown) => {
|
|
1951
|
+
const s = (data as { status?: string }).status
|
|
1952
|
+
return s === 'done' || s === 'failed' || s === 'timed_out'
|
|
1953
|
+
},
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
const searchJobsProviders: ProviderEntry[] = [
|
|
1957
|
+
{
|
|
1958
|
+
id: 'career_site_jobs',
|
|
1959
|
+
endpoint: '/career-site-jobs/search',
|
|
1960
|
+
method: 'POST',
|
|
1961
|
+
priority: 1,
|
|
1962
|
+
isApplicable: (input) => !_hasJobFilter(input, _LINKEDIN_ONLY_KEYS),
|
|
1963
|
+
mapParams: (input) => ({
|
|
1964
|
+
body: {
|
|
1965
|
+
limit: Math.min(Math.max((input.limit as number | undefined) ?? 25, 10), 500),
|
|
1966
|
+
timeRange: input.time_range,
|
|
1967
|
+
titleSearch: input.title_keywords,
|
|
1968
|
+
titleExclusionSearch: input.exclude_title_keywords,
|
|
1969
|
+
locationSearch: input.locations,
|
|
1970
|
+
locationExclusionSearch: input.exclude_locations,
|
|
1971
|
+
descriptionSearch: input.description_keywords,
|
|
1972
|
+
descriptionExclusionSearch: input.exclude_description_keywords,
|
|
1973
|
+
organizationSearch: input.companies,
|
|
1974
|
+
organizationExclusionSearch: input.exclude_companies,
|
|
1975
|
+
domainFilter: input.company_domains,
|
|
1976
|
+
domainExclusionFilter: input.exclude_company_domains,
|
|
1977
|
+
ats: input.ats_slugs,
|
|
1978
|
+
atsExclusionFilter: input.exclude_ats_slugs,
|
|
1979
|
+
descriptionType: input.include_description ? 'text' : undefined,
|
|
1980
|
+
remote: input.remote,
|
|
1981
|
+
removeAgency: input.exclude_agencies,
|
|
1982
|
+
datePostedAfter: input.posted_after,
|
|
1983
|
+
includeAi: true,
|
|
1984
|
+
aiEmploymentTypeFilter: input.employment_types,
|
|
1985
|
+
aiWorkArrangementFilter: input.work_arrangements,
|
|
1986
|
+
aiHasSalary: input.has_salary,
|
|
1987
|
+
aiExperienceLevelFilter: input.experience_levels,
|
|
1988
|
+
aiVisaSponsorshipFilter: input.has_visa_sponsorship,
|
|
1989
|
+
aiTaxonomiesFilter: input.taxonomies,
|
|
1990
|
+
},
|
|
1991
|
+
}),
|
|
1992
|
+
hasResult: (data) => isNonEmptyArray((data as { jobs?: unknown[] }).jobs),
|
|
1993
|
+
async: {
|
|
1994
|
+
..._jobsSharedAsync,
|
|
1995
|
+
pollEndpoint: (id) => `/career-site-jobs/search/${id}`,
|
|
1996
|
+
},
|
|
1997
|
+
},
|
|
1998
|
+
{
|
|
1999
|
+
id: 'linkedin_jobs_api',
|
|
2000
|
+
endpoint: '/linkedin-jobs-api/search',
|
|
2001
|
+
method: 'POST',
|
|
2002
|
+
priority: 2,
|
|
2003
|
+
isApplicable: (input) => !_hasJobFilter(input, _CAREER_SITE_ONLY_KEYS),
|
|
2004
|
+
mapParams: (input) => ({
|
|
2005
|
+
body: {
|
|
2006
|
+
limit: Math.min(Math.max((input.limit as number | undefined) ?? 25, 10), 500),
|
|
2007
|
+
timeRange: input.time_range,
|
|
2008
|
+
titleSearch: input.title_keywords,
|
|
2009
|
+
titleExclusionSearch: input.exclude_title_keywords,
|
|
2010
|
+
locationSearch: input.locations,
|
|
2011
|
+
// upstream field is intentionally misspelled — match it verbatim
|
|
2012
|
+
locationExclusionSeach: input.exclude_locations,
|
|
2013
|
+
descriptionSearch: input.description_keywords,
|
|
2014
|
+
descriptionExclusionSearch: input.exclude_description_keywords,
|
|
2015
|
+
organizationSearch: input.companies,
|
|
2016
|
+
organizationExclusionSearch: input.exclude_companies,
|
|
2017
|
+
organizationSlugFilter: input.organization_slugs,
|
|
2018
|
+
organizationSlugExclusionFilter: input.exclude_organization_slugs,
|
|
2019
|
+
industryFilter: input.industries,
|
|
2020
|
+
organizationEmployeesGte: input.min_employees,
|
|
2021
|
+
organizationEmployeesLte: input.max_employees,
|
|
2022
|
+
seniorityFilter: input.seniority_levels,
|
|
2023
|
+
descriptionType: input.include_description ? 'text' : undefined,
|
|
2024
|
+
remote: input.remote,
|
|
2025
|
+
removeAgency: input.exclude_agencies,
|
|
2026
|
+
datePostedAfter: input.posted_after,
|
|
2027
|
+
includeAi: true,
|
|
2028
|
+
// upstream uses PascalCase — match it verbatim
|
|
2029
|
+
EmploymentTypeFilter: input.employment_types,
|
|
2030
|
+
aiWorkArrangementFilter: input.work_arrangements,
|
|
2031
|
+
aiHasSalary: input.has_salary,
|
|
2032
|
+
aiExperienceLevelFilter: input.experience_levels,
|
|
2033
|
+
aiVisaSponsorshipFilter: input.has_visa_sponsorship,
|
|
2034
|
+
aiTaxonomiesFilter: input.taxonomies,
|
|
2035
|
+
directApply: input.easy_apply_only === true ? true : undefined,
|
|
2036
|
+
noDirectApply: input.exclude_easy_apply === true ? true : undefined,
|
|
2037
|
+
excludeATSDuplicate: true,
|
|
2038
|
+
},
|
|
2039
|
+
}),
|
|
2040
|
+
hasResult: (data) => isNonEmptyArray((data as { jobs?: unknown[] }).jobs),
|
|
2041
|
+
async: {
|
|
2042
|
+
..._jobsSharedAsync,
|
|
2043
|
+
pollEndpoint: (id) => `/linkedin-jobs-api/search/${id}`,
|
|
2044
|
+
},
|
|
2045
|
+
},
|
|
2046
|
+
{
|
|
2047
|
+
id: 'theirstack-jobs',
|
|
2048
|
+
endpoint: '/theirstack/jobs/search',
|
|
2049
|
+
method: 'POST',
|
|
2050
|
+
priority: 3,
|
|
2051
|
+
isApplicable: (input) =>
|
|
2052
|
+
isNonEmptyArray(input.title_keywords as unknown[]) ||
|
|
2053
|
+
isNonEmptyArray(input.description_keywords as unknown[]) ||
|
|
2054
|
+
isNonEmptyArray(input.companies as unknown[]) ||
|
|
2055
|
+
isNonEmptyArray(input.company_domains as unknown[]),
|
|
2056
|
+
mapParams: (input) => ({
|
|
2057
|
+
body: {
|
|
2058
|
+
job_title_pattern_or: input.title_keywords,
|
|
2059
|
+
job_description_pattern_or: input.description_keywords,
|
|
2060
|
+
company_name_or: input.companies,
|
|
2061
|
+
company_domain_or: input.company_domains,
|
|
2062
|
+
posted_at_gte: input.posted_after,
|
|
2063
|
+
limit: input.limit,
|
|
2064
|
+
},
|
|
2065
|
+
}),
|
|
2066
|
+
hasResult: (data) => isNonEmptyArray((data as { data?: unknown[] }).data),
|
|
2067
|
+
},
|
|
2068
|
+
]
|
|
2069
|
+
|
|
2070
|
+
// ---------------------------------------------------------------------------
|
|
2071
|
+
// search_ads
|
|
2072
|
+
// ---------------------------------------------------------------------------
|
|
2073
|
+
|
|
2074
|
+
const _adsSharedAsync = {
|
|
2075
|
+
pollIntervalMs: 5_000,
|
|
2076
|
+
timeoutMs: 300_000,
|
|
2077
|
+
extractId: (data: unknown) => {
|
|
2078
|
+
const jobId = (data as { jobId?: number | string }).jobId
|
|
2079
|
+
if (jobId === undefined || jobId === null || jobId === '') {
|
|
2080
|
+
throw new Error('Ads response has no jobId')
|
|
2081
|
+
}
|
|
2082
|
+
return String(jobId)
|
|
2083
|
+
},
|
|
2084
|
+
isComplete: (data: unknown) => {
|
|
2085
|
+
const s = (data as { status?: string }).status
|
|
2086
|
+
return s === 'done' || s === 'failed' || s === 'timed_out'
|
|
2087
|
+
},
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function _adPlatformAllows(input: Record<string, unknown>, mine: string): boolean {
|
|
2091
|
+
const p = input.platform
|
|
2092
|
+
return p === undefined || p === null || p === '' || p === mine
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
const searchAdsProviders: ProviderEntry[] = [
|
|
2096
|
+
{
|
|
2097
|
+
id: 'google_ads',
|
|
2098
|
+
endpoint: '/google-ads/search',
|
|
2099
|
+
method: 'POST',
|
|
2100
|
+
priority: 1,
|
|
2101
|
+
isApplicable: (input) => {
|
|
2102
|
+
if (!_adPlatformAllows(input, 'google')) return false
|
|
2103
|
+
return !!input.query || isNonEmptyArray(input.domains) || isNonEmptyArray(input.advertiser_ids)
|
|
2104
|
+
},
|
|
2105
|
+
mapParams: (input) => ({
|
|
2106
|
+
body: {
|
|
2107
|
+
searchTerms: input.query ? [input.query] : undefined,
|
|
2108
|
+
domains: input.domains,
|
|
2109
|
+
advertiserIds: input.advertiser_ids,
|
|
2110
|
+
region: input.country,
|
|
2111
|
+
maxAds: Math.min(Math.max((input.max_results as number | undefined) ?? 25, 1), 1000),
|
|
2112
|
+
},
|
|
2113
|
+
}),
|
|
2114
|
+
hasResult: (data) => isNonEmptyArray((data as { ads?: unknown[] }).ads),
|
|
2115
|
+
async: { ..._adsSharedAsync, pollEndpoint: (id) => `/google-ads/search/${id}` },
|
|
2116
|
+
},
|
|
2117
|
+
{
|
|
2118
|
+
id: 'linkedin_ad_library',
|
|
2119
|
+
endpoint: '/linkedin-ad-library/search',
|
|
2120
|
+
method: 'POST',
|
|
2121
|
+
priority: 2,
|
|
2122
|
+
isApplicable: (input) => {
|
|
2123
|
+
if (!_adPlatformAllows(input, 'linkedin')) return false
|
|
2124
|
+
return isNonEmptyArray(input.search_urls)
|
|
2125
|
+
},
|
|
2126
|
+
mapParams: (input) => ({
|
|
2127
|
+
body: {
|
|
2128
|
+
searchUrls: input.search_urls,
|
|
2129
|
+
maxResults: Math.min(Math.max((input.max_results as number | undefined) ?? 25, 1), 200),
|
|
2130
|
+
},
|
|
2131
|
+
}),
|
|
2132
|
+
hasResult: (data) => isNonEmptyArray((data as { ads?: unknown[] }).ads),
|
|
2133
|
+
async: { ..._adsSharedAsync, pollEndpoint: (id) => `/linkedin-ad-library/search/${id}` },
|
|
2134
|
+
},
|
|
2135
|
+
{
|
|
2136
|
+
id: 'meta_ads',
|
|
2137
|
+
endpoint: '/meta-ads/search',
|
|
2138
|
+
method: 'POST',
|
|
2139
|
+
priority: 3,
|
|
2140
|
+
isApplicable: (input) => {
|
|
2141
|
+
if (!_adPlatformAllows(input, 'meta')) return false
|
|
2142
|
+
if (isNonEmptyArray(input.domains)) return false
|
|
2143
|
+
if (isNonEmptyArray(input.advertiser_ids)) return false
|
|
2144
|
+
if (isNonEmptyArray(input.search_urls)) return false
|
|
2145
|
+
return !!input.query
|
|
2146
|
+
},
|
|
2147
|
+
mapParams: (input) => ({
|
|
2148
|
+
body: {
|
|
2149
|
+
search: input.query,
|
|
2150
|
+
country: input.country ?? 'US',
|
|
2151
|
+
adType: input.ad_type ?? 'ALL',
|
|
2152
|
+
maxItems: Math.min(Math.max((input.max_results as number | undefined) ?? 20, 1), 200),
|
|
2153
|
+
},
|
|
2154
|
+
}),
|
|
2155
|
+
hasResult: (data) => isNonEmptyArray((data as { ads?: unknown[] }).ads),
|
|
2156
|
+
async: { ..._adsSharedAsync, pollEndpoint: (id) => `/meta-ads/search/${id}` },
|
|
2157
|
+
},
|
|
2158
|
+
{
|
|
2159
|
+
id: 'twitter_ads',
|
|
2160
|
+
endpoint: '/twitter-ads-scraper/search',
|
|
2161
|
+
method: 'POST',
|
|
2162
|
+
priority: 4,
|
|
2163
|
+
isApplicable: (input) => {
|
|
2164
|
+
if (!_adPlatformAllows(input, 'twitter')) return false
|
|
2165
|
+
if (isNonEmptyArray(input.domains)) return false
|
|
2166
|
+
if (isNonEmptyArray(input.advertiser_ids)) return false
|
|
2167
|
+
if (isNonEmptyArray(input.search_urls)) return false
|
|
2168
|
+
return !!input.query
|
|
2169
|
+
},
|
|
2170
|
+
mapParams: (input) => ({
|
|
2171
|
+
body: {
|
|
2172
|
+
searchTerms: [input.query],
|
|
2173
|
+
country: input.country,
|
|
2174
|
+
startDate: input.start_date,
|
|
2175
|
+
endDate: input.end_date,
|
|
2176
|
+
maxItems: Math.min(Math.max((input.max_results as number | undefined) ?? 20, 1), 100),
|
|
2177
|
+
},
|
|
2178
|
+
}),
|
|
2179
|
+
hasResult: (data) => isNonEmptyArray((data as { ads?: unknown[] }).ads),
|
|
2180
|
+
async: { ..._adsSharedAsync, pollEndpoint: (id) => `/twitter-ads-scraper/search/${id}` },
|
|
2181
|
+
},
|
|
2182
|
+
{
|
|
2183
|
+
id: 'reddit_ads',
|
|
2184
|
+
endpoint: '/reddit-ads/scrape',
|
|
2185
|
+
method: 'POST',
|
|
2186
|
+
priority: 5,
|
|
2187
|
+
isApplicable: (input) => {
|
|
2188
|
+
if (!_adPlatformAllows(input, 'reddit')) return false
|
|
2189
|
+
if (isNonEmptyArray(input.domains)) return false
|
|
2190
|
+
if (isNonEmptyArray(input.advertiser_ids)) return false
|
|
2191
|
+
if (isNonEmptyArray(input.search_urls)) return false
|
|
2192
|
+
return true
|
|
2193
|
+
},
|
|
2194
|
+
mapParams: (input) => ({
|
|
2195
|
+
body: {
|
|
2196
|
+
keywords: input.query,
|
|
2197
|
+
industry: input.industry,
|
|
2198
|
+
budgetCategory: input.budget_category,
|
|
2199
|
+
postType: input.post_type,
|
|
2200
|
+
objectiveType: input.objective_type,
|
|
2201
|
+
},
|
|
2202
|
+
}),
|
|
2203
|
+
hasResult: (data) => isNonEmptyArray((data as { ads?: unknown[] }).ads),
|
|
2204
|
+
async: { ..._adsSharedAsync, pollEndpoint: (id) => `/reddit-ads/scrape/${id}` },
|
|
2205
|
+
},
|
|
2206
|
+
]
|
|
2207
|
+
|
|
2208
|
+
// ---------------------------------------------------------------------------
|
|
2209
|
+
// search_places
|
|
2210
|
+
// ---------------------------------------------------------------------------
|
|
2211
|
+
|
|
2212
|
+
const _OPENMART_COUNTRIES = ['US', 'CA', 'AU', 'PR', 'NZ'] as const
|
|
2213
|
+
|
|
2214
|
+
const _OPENMART_ONLY_KEYS = [
|
|
2215
|
+
'tags', 'state', 'zip_code', 'lat', 'long', 'geo_radius',
|
|
2216
|
+
'min_overall_rating', 'max_overall_rating', 'min_total_reviews', 'max_total_reviews',
|
|
2217
|
+
'ownership_type', 'has_website', 'has_valid_website', 'has_contact_info',
|
|
2218
|
+
'min_price_tier', 'max_price_tier',
|
|
2219
|
+
'include_keywords', 'exclude_keywords', 'exclude_root_domains',
|
|
2220
|
+
] as const
|
|
2221
|
+
|
|
2222
|
+
const _GOOGLE_MAPS_ONLY_KEYS = [
|
|
2223
|
+
'start_urls', 'include_opening_hours', 'include_additional_info', 'language',
|
|
2224
|
+
] as const
|
|
2225
|
+
|
|
2226
|
+
function _placeProviderAllows(input: Record<string, unknown>, mine: string): boolean {
|
|
2227
|
+
const p = input.provider
|
|
2228
|
+
return p === undefined || p === null || p === '' || p === mine
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function _hasAny(input: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
2232
|
+
for (const k of keys) {
|
|
2233
|
+
const v = input[k]
|
|
2234
|
+
if (v === undefined || v === null) continue
|
|
2235
|
+
if (typeof v === 'string' && v === '') continue
|
|
2236
|
+
if (Array.isArray(v) && v.length === 0) continue
|
|
2237
|
+
return true
|
|
2238
|
+
}
|
|
2239
|
+
return false
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
const _placesSharedAsync = {
|
|
2243
|
+
pollIntervalMs: 5_000,
|
|
2244
|
+
timeoutMs: 300_000,
|
|
2245
|
+
extractId: (data: unknown) => {
|
|
2246
|
+
const jobId = (data as { jobId?: number | string }).jobId
|
|
2247
|
+
if (jobId === undefined || jobId === null || jobId === '') {
|
|
2248
|
+
throw new Error('Places response has no jobId')
|
|
2249
|
+
}
|
|
2250
|
+
return String(jobId)
|
|
2251
|
+
},
|
|
2252
|
+
isComplete: (data: unknown) => {
|
|
2253
|
+
const s = (data as { status?: string }).status
|
|
2254
|
+
return s === 'done' || s === 'failed' || s === 'timed_out'
|
|
2255
|
+
},
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
const searchPlacesProviders: ProviderEntry[] = [
|
|
2259
|
+
{
|
|
2260
|
+
id: 'openmart',
|
|
2261
|
+
endpoint: '/openmart/search',
|
|
2262
|
+
method: 'POST',
|
|
2263
|
+
priority: 1,
|
|
2264
|
+
isApplicable: (input) => {
|
|
2265
|
+
if (!_placeProviderAllows(input, 'openmart')) return false
|
|
2266
|
+
if (_hasAny(input, _GOOGLE_MAPS_ONLY_KEYS)) return false
|
|
2267
|
+
const c = input.country
|
|
2268
|
+
if (typeof c === 'string' && c.length > 0
|
|
2269
|
+
&& !(_OPENMART_COUNTRIES as readonly string[]).includes(c.toUpperCase())) {
|
|
2270
|
+
return false
|
|
2271
|
+
}
|
|
2272
|
+
return !!input.query
|
|
2273
|
+
|| isNonEmptyArray(input.tags)
|
|
2274
|
+
|| _hasAny(input, ['city', 'state', 'zip_code', 'lat', 'long'])
|
|
2275
|
+
|| _hasAny(input, _OPENMART_ONLY_KEYS)
|
|
2276
|
+
},
|
|
2277
|
+
mapParams: (input) => {
|
|
2278
|
+
const country = typeof input.country === 'string' && input.country.length > 0
|
|
2279
|
+
? input.country.toUpperCase() : undefined
|
|
2280
|
+
const hasLocationBits = _hasAny(input, ['city', 'state', 'zip_code', 'lat', 'long', 'geo_radius']) || !!country
|
|
2281
|
+
const location = hasLocationBits ? {
|
|
2282
|
+
city: input.city,
|
|
2283
|
+
state: input.state,
|
|
2284
|
+
zip_code: input.zip_code,
|
|
2285
|
+
country,
|
|
2286
|
+
lat: input.lat,
|
|
2287
|
+
long: input.long,
|
|
2288
|
+
geo_radius: input.geo_radius,
|
|
2289
|
+
} : undefined
|
|
2290
|
+
return {
|
|
2291
|
+
body: {
|
|
2292
|
+
query: input.query,
|
|
2293
|
+
tags: input.tags,
|
|
2294
|
+
location,
|
|
2295
|
+
min_overall_rating: input.min_overall_rating,
|
|
2296
|
+
max_overall_rating: input.max_overall_rating,
|
|
2297
|
+
min_total_reviews: input.min_total_reviews,
|
|
2298
|
+
max_total_reviews: input.max_total_reviews,
|
|
2299
|
+
ownership_type: input.ownership_type,
|
|
2300
|
+
has_website: input.has_website,
|
|
2301
|
+
has_valid_website: input.has_valid_website,
|
|
2302
|
+
has_contact_info: input.has_contact_info,
|
|
2303
|
+
min_price_tier: input.min_price_tier,
|
|
2304
|
+
max_price_tier: input.max_price_tier,
|
|
2305
|
+
include_keywords: input.include_keywords,
|
|
2306
|
+
exclude_keywords: input.exclude_keywords,
|
|
2307
|
+
exclude_root_domains: input.exclude_root_domains,
|
|
2308
|
+
limit: Math.min(Math.max((input.limit as number | undefined) ?? 25, 1), 500),
|
|
2309
|
+
},
|
|
2310
|
+
}
|
|
2311
|
+
},
|
|
2312
|
+
hasResult: (data) => {
|
|
2313
|
+
if (Array.isArray(data)) return data.length > 0
|
|
2314
|
+
const inner = (data as { data?: unknown[] }).data
|
|
2315
|
+
return Array.isArray(inner) && inner.length > 0
|
|
2316
|
+
},
|
|
2317
|
+
},
|
|
2318
|
+
{
|
|
2319
|
+
id: 'google_maps',
|
|
2320
|
+
endpoint: '/google-maps/scraper',
|
|
2321
|
+
method: 'POST',
|
|
2322
|
+
priority: 2,
|
|
2323
|
+
isApplicable: (input) => {
|
|
2324
|
+
if (!_placeProviderAllows(input, 'google_maps')) return false
|
|
2325
|
+
return !!input.query
|
|
2326
|
+
|| isNonEmptyArray(input.start_urls)
|
|
2327
|
+
|| (typeof input.city === 'string' && input.city.length > 0)
|
|
2328
|
+
},
|
|
2329
|
+
mapParams: (input) => {
|
|
2330
|
+
const startUrls = Array.isArray(input.start_urls)
|
|
2331
|
+
? (input.start_urls as string[]).map((url) => ({ url }))
|
|
2332
|
+
: undefined
|
|
2333
|
+
const country = typeof input.country === 'string' && input.country.length > 0
|
|
2334
|
+
? input.country.toLowerCase() : undefined
|
|
2335
|
+
return {
|
|
2336
|
+
body: {
|
|
2337
|
+
searchStringsArray: input.query ? [input.query] : undefined,
|
|
2338
|
+
startUrls,
|
|
2339
|
+
maxCrawledPlacesPerSearch: Math.min(Math.max((input.limit as number | undefined) ?? 25, 1), 200),
|
|
2340
|
+
countryCode: country,
|
|
2341
|
+
city: input.city,
|
|
2342
|
+
language: input.language,
|
|
2343
|
+
includeOpeningHours: input.include_opening_hours,
|
|
2344
|
+
additionalInfo: input.include_additional_info,
|
|
2345
|
+
},
|
|
2346
|
+
}
|
|
2347
|
+
},
|
|
2348
|
+
hasResult: (data) => isNonEmptyArray((data as { places?: unknown[] }).places),
|
|
2349
|
+
async: { ..._placesSharedAsync, pollEndpoint: (id) => `/google-maps/scraper/${id}` },
|
|
2350
|
+
},
|
|
2351
|
+
]
|
|
2352
|
+
|
|
2353
|
+
// ---------------------------------------------------------------------------
|
|
2354
|
+
// find_influencers
|
|
2355
|
+
// ---------------------------------------------------------------------------
|
|
2356
|
+
|
|
2357
|
+
const findInfluencersProviders: ProviderEntry[] = [
|
|
2358
|
+
{
|
|
2359
|
+
id: 'influencers_similar',
|
|
2360
|
+
endpoint: '/influencers-club/discovery/creators/similar',
|
|
2361
|
+
method: 'POST',
|
|
2362
|
+
priority: 1,
|
|
2363
|
+
isApplicable: (input) => !!input.handle,
|
|
2364
|
+
mapParams: (input) => ({
|
|
2365
|
+
body: {
|
|
2366
|
+
handle: input.handle,
|
|
2367
|
+
platform: input.platform,
|
|
2368
|
+
paging: { limit: (input.limit as number) ?? 25, page: (input.page as number) ?? 1 },
|
|
2369
|
+
filters: {
|
|
2370
|
+
location: input.location,
|
|
2371
|
+
gender: input.gender,
|
|
2372
|
+
type: input.type,
|
|
2373
|
+
},
|
|
2374
|
+
},
|
|
2375
|
+
}),
|
|
2376
|
+
hasResult: (data) => {
|
|
2377
|
+
const d = data as { accounts?: unknown[]; creators?: unknown[] }
|
|
2378
|
+
return isNonEmptyArray(d.accounts) || isNonEmptyArray(d.creators)
|
|
2379
|
+
},
|
|
2380
|
+
},
|
|
2381
|
+
{
|
|
2382
|
+
id: 'influencers_discovery',
|
|
2383
|
+
endpoint: '/influencers-club/discovery',
|
|
2384
|
+
method: 'POST',
|
|
2385
|
+
priority: 2,
|
|
2386
|
+
mapParams: (input) => ({
|
|
2387
|
+
body: {
|
|
2388
|
+
platform: input.platform,
|
|
2389
|
+
paging: { limit: (input.limit as number) ?? 25, page: (input.page as number) ?? 1 },
|
|
2390
|
+
sort: input.sort_by
|
|
2391
|
+
? { sort_by: input.sort_by, sort_order: (input.sort_order as string) ?? 'desc' }
|
|
2392
|
+
: undefined,
|
|
2393
|
+
filters: {
|
|
2394
|
+
ai_search: input.ai_search,
|
|
2395
|
+
location: input.location,
|
|
2396
|
+
gender: input.gender,
|
|
2397
|
+
type: input.type,
|
|
2398
|
+
},
|
|
2399
|
+
},
|
|
2400
|
+
}),
|
|
2401
|
+
hasResult: (data) => {
|
|
2402
|
+
const d = data as { accounts?: unknown[]; creators?: unknown[] }
|
|
2403
|
+
return isNonEmptyArray(d.accounts) || isNonEmptyArray(d.creators)
|
|
2404
|
+
},
|
|
2405
|
+
},
|
|
2406
|
+
]
|
|
2407
|
+
|
|
2408
|
+
// ---------------------------------------------------------------------------
|
|
2409
|
+
// search_reddit
|
|
2410
|
+
// ---------------------------------------------------------------------------
|
|
2411
|
+
|
|
2412
|
+
const _redditSharedAsync = {
|
|
2413
|
+
pollIntervalMs: 5_000,
|
|
2414
|
+
timeoutMs: 300_000,
|
|
2415
|
+
extractId: (data: unknown) => {
|
|
2416
|
+
const jobId = (data as { jobId?: number | string }).jobId
|
|
2417
|
+
if (jobId === undefined || jobId === null || jobId === '') {
|
|
2418
|
+
throw new Error('Reddit response has no jobId')
|
|
2419
|
+
}
|
|
2420
|
+
return String(jobId)
|
|
2421
|
+
},
|
|
2422
|
+
isComplete: (data: unknown) => {
|
|
2423
|
+
const s = (data as { status?: string }).status
|
|
2424
|
+
return s === 'done' || s === 'failed' || s === 'timed_out'
|
|
2425
|
+
},
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
const searchRedditProviders: ProviderEntry[] = [
|
|
2429
|
+
{
|
|
2430
|
+
id: 'reddit',
|
|
2431
|
+
endpoint: '/reddit/scrape',
|
|
2432
|
+
method: 'POST',
|
|
2433
|
+
priority: 1,
|
|
2434
|
+
isApplicable: (input) => isNonEmptyArray(input.start_urls),
|
|
2435
|
+
mapParams: (input) => ({
|
|
2436
|
+
body: {
|
|
2437
|
+
searchQueries: input.query ? [input.query] : undefined,
|
|
2438
|
+
startUrls: (input.start_urls as string[] | undefined)?.map((url) => ({ url })),
|
|
2439
|
+
type: input.type ?? 'posts',
|
|
2440
|
+
sort: input.sort,
|
|
2441
|
+
time: input.time,
|
|
2442
|
+
maxItems: Math.min(Math.max((input.limit as number | undefined) ?? 10, 1), 200),
|
|
2443
|
+
maxCommentsPerPost: input.max_comments_per_post,
|
|
2444
|
+
includeComments: input.include_comments,
|
|
2445
|
+
},
|
|
2446
|
+
}),
|
|
2447
|
+
hasResult: (data) => isNonEmptyArray((data as { items?: unknown[] }).items),
|
|
2448
|
+
async: {
|
|
2449
|
+
..._redditSharedAsync,
|
|
2450
|
+
pollEndpoint: (id) => `/reddit/scrape/${id}`,
|
|
2451
|
+
},
|
|
2452
|
+
},
|
|
2453
|
+
]
|
|
2454
|
+
|
|
2455
|
+
// ---------------------------------------------------------------------------
|
|
2456
|
+
// search_seo
|
|
2457
|
+
// ---------------------------------------------------------------------------
|
|
2458
|
+
|
|
2459
|
+
// DataForSEO wraps all responses as { tasks: [{ result: [...] }] }.
|
|
2460
|
+
// This helper extracts the first result object to simplify hasResult checks.
|
|
2461
|
+
function dfsResult(data: unknown): Record<string, unknown> | null {
|
|
2462
|
+
const d = data as { tasks?: Array<{ result?: unknown[] }> }
|
|
2463
|
+
const result = d?.tasks?.[0]?.result
|
|
2464
|
+
if (!Array.isArray(result) || result.length === 0) return null
|
|
2465
|
+
return result[0] as Record<string, unknown>
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
const searchSeoProviders: ProviderEntry[] = [
|
|
2469
|
+
{
|
|
2470
|
+
id: 'kw_search_volume',
|
|
2471
|
+
endpoint: '/dataforseo/keywords/google-ads/search-volume',
|
|
2472
|
+
method: 'POST',
|
|
2473
|
+
priority: 1,
|
|
2474
|
+
isApplicable: (input) => input.category === 'keywords' && isNonEmptyArray(input.keywords),
|
|
2475
|
+
mapParams: (input) => ({
|
|
2476
|
+
body: {
|
|
2477
|
+
keywords: input.keywords,
|
|
2478
|
+
location_name: input.location,
|
|
2479
|
+
language_code: input.language,
|
|
2480
|
+
},
|
|
2481
|
+
}),
|
|
2482
|
+
hasResult: (data) => {
|
|
2483
|
+
const d = data as { tasks?: Array<{ result?: unknown[] }> }
|
|
2484
|
+
return isNonEmptyArray(d?.tasks?.[0]?.result)
|
|
2485
|
+
},
|
|
2486
|
+
},
|
|
2487
|
+
{
|
|
2488
|
+
id: 'kw_trends',
|
|
2489
|
+
endpoint: '/dataforseo/keywords/google-trends/explore',
|
|
2490
|
+
method: 'POST',
|
|
2491
|
+
priority: 2,
|
|
2492
|
+
isApplicable: (input) => input.category === 'keywords' && isNonEmptyArray(input.keywords),
|
|
2493
|
+
mapParams: (input) => ({
|
|
2494
|
+
body: {
|
|
2495
|
+
keywords: input.keywords,
|
|
2496
|
+
location_name: input.location,
|
|
2497
|
+
language_code: input.language,
|
|
2498
|
+
date_from: input.date_from,
|
|
2499
|
+
date_to: input.date_to,
|
|
2500
|
+
time_range: input.time_range,
|
|
2501
|
+
},
|
|
2502
|
+
}),
|
|
2503
|
+
hasResult: (data) => isNonEmptyArray(dfsResult(data)?.items),
|
|
2504
|
+
},
|
|
2505
|
+
{
|
|
2506
|
+
id: 'serp_google',
|
|
2507
|
+
endpoint: '/dataforseo/serp/google/organic',
|
|
2508
|
+
method: 'POST',
|
|
2509
|
+
priority: 3,
|
|
2510
|
+
isApplicable: (input) =>
|
|
2511
|
+
input.category === 'serp' && !!input.keyword && input.engine !== 'youtube' && input.engine !== 'bing',
|
|
2512
|
+
mapParams: (input) => ({
|
|
2513
|
+
body: {
|
|
2514
|
+
keyword: input.keyword,
|
|
2515
|
+
location_name: (input.location as string | undefined) ?? 'United States',
|
|
2516
|
+
language_code: (input.language as string | undefined) ?? 'en',
|
|
2517
|
+
depth: input.limit,
|
|
2518
|
+
device: input.device,
|
|
2519
|
+
},
|
|
2520
|
+
}),
|
|
2521
|
+
hasResult: (data) => isNonEmptyArray(dfsResult(data)?.items),
|
|
2522
|
+
},
|
|
2523
|
+
{
|
|
2524
|
+
id: 'serp_bing',
|
|
2525
|
+
endpoint: '/dataforseo/serp/bing/organic',
|
|
2526
|
+
method: 'POST',
|
|
2527
|
+
priority: 4,
|
|
2528
|
+
isApplicable: (input) => input.category === 'serp' && !!input.keyword && input.engine === 'bing',
|
|
2529
|
+
mapParams: (input) => ({
|
|
2530
|
+
body: {
|
|
2531
|
+
keyword: input.keyword,
|
|
2532
|
+
location_name: (input.location as string | undefined) ?? 'United States',
|
|
2533
|
+
language_code: (input.language as string | undefined) ?? 'en',
|
|
2534
|
+
depth: input.limit,
|
|
2535
|
+
device: input.device,
|
|
2536
|
+
},
|
|
2537
|
+
}),
|
|
2538
|
+
hasResult: (data) => isNonEmptyArray(dfsResult(data)?.items),
|
|
2539
|
+
},
|
|
2540
|
+
{
|
|
2541
|
+
id: 'serp_youtube',
|
|
2542
|
+
endpoint: '/dataforseo/serp/youtube/organic',
|
|
2543
|
+
method: 'POST',
|
|
2544
|
+
priority: 5,
|
|
2545
|
+
isApplicable: (input) => input.category === 'serp' && !!input.keyword && input.engine === 'youtube',
|
|
2546
|
+
mapParams: (input) => ({
|
|
2547
|
+
body: {
|
|
2548
|
+
keyword: input.keyword,
|
|
2549
|
+
location_name: (input.location as string | undefined) ?? 'United States',
|
|
2550
|
+
language_code: (input.language as string | undefined) ?? 'en',
|
|
2551
|
+
block_depth: input.limit,
|
|
2552
|
+
device: input.device,
|
|
2553
|
+
},
|
|
2554
|
+
}),
|
|
2555
|
+
hasResult: (data) => isNonEmptyArray(dfsResult(data)?.items),
|
|
2556
|
+
},
|
|
2557
|
+
{
|
|
2558
|
+
id: 'bl_summary',
|
|
2559
|
+
endpoint: '/dataforseo/backlinks/summary',
|
|
2560
|
+
method: 'POST',
|
|
2561
|
+
priority: 6,
|
|
2562
|
+
isApplicable: (input) => input.category === 'backlinks' && !!input.target,
|
|
2563
|
+
mapParams: (input) => ({ body: { target: input.target } }),
|
|
2564
|
+
hasResult: (data) => {
|
|
2565
|
+
const r = dfsResult(data)
|
|
2566
|
+
return !!(r?.total_count) || !!(r?.rank)
|
|
2567
|
+
},
|
|
2568
|
+
},
|
|
2569
|
+
{
|
|
2570
|
+
id: 'bl_backlinks',
|
|
2571
|
+
endpoint: '/dataforseo/backlinks/backlinks',
|
|
2572
|
+
method: 'POST',
|
|
2573
|
+
priority: 7,
|
|
2574
|
+
isApplicable: (input) => input.category === 'backlinks' && !!input.target,
|
|
2575
|
+
mapParams: (input) => ({
|
|
2576
|
+
body: { target: input.target, limit: input.limit, offset: 0 },
|
|
2577
|
+
}),
|
|
2578
|
+
hasResult: (data) => isNonEmptyArray(dfsResult(data)?.items),
|
|
2579
|
+
},
|
|
2580
|
+
{
|
|
2581
|
+
id: 'bl_referring',
|
|
2582
|
+
endpoint: '/dataforseo/backlinks/referring-domains',
|
|
2583
|
+
method: 'POST',
|
|
2584
|
+
priority: 8,
|
|
2585
|
+
isApplicable: (input) => input.category === 'backlinks' && !!input.target,
|
|
2586
|
+
mapParams: (input) => ({
|
|
2587
|
+
body: { target: input.target, limit: input.limit, offset: 0 },
|
|
2588
|
+
}),
|
|
2589
|
+
hasResult: (data) => isNonEmptyArray(dfsResult(data)?.items),
|
|
2590
|
+
},
|
|
2591
|
+
{
|
|
2592
|
+
id: 'domain_tech',
|
|
2593
|
+
endpoint: '/dataforseo/domain-analytics/technologies',
|
|
2594
|
+
method: 'POST',
|
|
2595
|
+
priority: 9,
|
|
2596
|
+
isApplicable: (input) =>
|
|
2597
|
+
input.category === 'domain' && !!input.target && input.action !== 'whois',
|
|
2598
|
+
mapParams: (input) => ({ body: { target: input.target } }),
|
|
2599
|
+
hasResult: (data) => {
|
|
2600
|
+
const r = dfsResult(data)
|
|
2601
|
+
return !!r && hasAnyKey(r, ['technologies', 'cms', 'analytics', 'widgets'])
|
|
2602
|
+
},
|
|
2603
|
+
},
|
|
2604
|
+
{
|
|
2605
|
+
id: 'domain_whois',
|
|
2606
|
+
endpoint: '/dataforseo/domain-analytics/whois',
|
|
2607
|
+
method: 'POST',
|
|
2608
|
+
priority: 10,
|
|
2609
|
+
isApplicable: (input) =>
|
|
2610
|
+
input.category === 'domain' && !!input.target && input.action === 'whois',
|
|
2611
|
+
mapParams: (input) => ({
|
|
2612
|
+
body: { limit: input.limit, filters: [['domain', '=', input.target]] },
|
|
2613
|
+
}),
|
|
2614
|
+
hasResult: (data) => isNonEmptyArray(dfsResult(data)?.items),
|
|
2615
|
+
},
|
|
2616
|
+
{
|
|
2617
|
+
id: 'labs_rank_overview',
|
|
2618
|
+
endpoint: '/dataforseo/labs/domain-rank-overview',
|
|
2619
|
+
method: 'POST',
|
|
2620
|
+
priority: 11,
|
|
2621
|
+
isApplicable: (input) =>
|
|
2622
|
+
input.category === 'labs' && !!input.target &&
|
|
2623
|
+
(!input.lab_action || input.lab_action === 'rank-overview'),
|
|
2624
|
+
mapParams: (input) => ({
|
|
2625
|
+
body: {
|
|
2626
|
+
target: input.target,
|
|
2627
|
+
location_name: input.location,
|
|
2628
|
+
language_code: input.language,
|
|
2629
|
+
},
|
|
2630
|
+
}),
|
|
2631
|
+
hasResult: (data) => {
|
|
2632
|
+
const r = dfsResult(data)
|
|
2633
|
+
return !!r && hasAnyKey(r, ['metrics', 'rank', 'organic', 'spam_score'])
|
|
2634
|
+
},
|
|
2635
|
+
},
|
|
2636
|
+
{
|
|
2637
|
+
id: 'labs_ranked_kw',
|
|
2638
|
+
endpoint: '/dataforseo/labs/ranked-keywords',
|
|
2639
|
+
method: 'POST',
|
|
2640
|
+
priority: 12,
|
|
2641
|
+
isApplicable: (input) =>
|
|
2642
|
+
input.category === 'labs' && !!input.target && input.lab_action === 'ranked-keywords',
|
|
2643
|
+
mapParams: (input) => ({
|
|
2644
|
+
body: {
|
|
2645
|
+
target: input.target,
|
|
2646
|
+
location_name: input.location,
|
|
2647
|
+
language_code: input.language,
|
|
2648
|
+
limit: input.limit,
|
|
2649
|
+
},
|
|
2650
|
+
}),
|
|
2651
|
+
hasResult: (data) => isNonEmptyArray(dfsResult(data)?.items),
|
|
2652
|
+
},
|
|
2653
|
+
{
|
|
2654
|
+
id: 'labs_competitors',
|
|
2655
|
+
endpoint: '/dataforseo/labs/competitors-domain',
|
|
2656
|
+
method: 'POST',
|
|
2657
|
+
priority: 13,
|
|
2658
|
+
isApplicable: (input) =>
|
|
2659
|
+
input.category === 'labs' && !!input.target && input.lab_action === 'competitors',
|
|
2660
|
+
mapParams: (input) => ({
|
|
2661
|
+
body: {
|
|
2662
|
+
target: input.target,
|
|
2663
|
+
location_name: input.location,
|
|
2664
|
+
language_code: input.language,
|
|
2665
|
+
limit: input.limit,
|
|
2666
|
+
},
|
|
2667
|
+
}),
|
|
2668
|
+
hasResult: (data) => isNonEmptyArray(dfsResult(data)?.items),
|
|
2669
|
+
},
|
|
2670
|
+
{
|
|
2671
|
+
id: 'labs_kw_ideas',
|
|
2672
|
+
endpoint: '/dataforseo/labs/keyword-ideas',
|
|
2673
|
+
method: 'POST',
|
|
2674
|
+
priority: 14,
|
|
2675
|
+
isApplicable: (input) =>
|
|
2676
|
+
input.category === 'labs' && isNonEmptyArray(input.keywords) && input.lab_action === 'keyword-ideas',
|
|
2677
|
+
mapParams: (input) => ({
|
|
2678
|
+
body: {
|
|
2679
|
+
keywords: input.keywords,
|
|
2680
|
+
location_name: input.location,
|
|
2681
|
+
language_code: input.language,
|
|
2682
|
+
limit: input.limit,
|
|
2683
|
+
},
|
|
2684
|
+
}),
|
|
2685
|
+
hasResult: (data) => isNonEmptyArray(dfsResult(data)?.items),
|
|
2686
|
+
},
|
|
2687
|
+
{
|
|
2688
|
+
id: 'page_lighthouse',
|
|
2689
|
+
endpoint: '/dataforseo/on-page/lighthouse',
|
|
2690
|
+
method: 'POST',
|
|
2691
|
+
priority: 15,
|
|
2692
|
+
isApplicable: (input) =>
|
|
2693
|
+
input.category === 'page' && !!input.url && input.page_action !== 'content',
|
|
2694
|
+
mapParams: (input) => ({
|
|
2695
|
+
body: {
|
|
2696
|
+
url: input.url,
|
|
2697
|
+
enable_javascript: input.enable_javascript,
|
|
2698
|
+
full_data: input.full_data,
|
|
2699
|
+
},
|
|
2700
|
+
}),
|
|
2701
|
+
hasResult: (data) => { const r = dfsResult(data); return !!r && hasAnyKey(r, ['categories', 'audits', 'lighthouse']) },
|
|
2702
|
+
},
|
|
2703
|
+
{
|
|
2704
|
+
id: 'page_content',
|
|
2705
|
+
endpoint: '/dataforseo/on-page/content-parsing',
|
|
2706
|
+
method: 'POST',
|
|
2707
|
+
priority: 16,
|
|
2708
|
+
isApplicable: (input) =>
|
|
2709
|
+
input.category === 'page' && !!input.url && input.page_action === 'content',
|
|
2710
|
+
mapParams: (input) => ({
|
|
2711
|
+
body: {
|
|
2712
|
+
url: input.url,
|
|
2713
|
+
enable_javascript: input.enable_javascript,
|
|
2714
|
+
},
|
|
2715
|
+
}),
|
|
2716
|
+
hasResult: (data) => { const r = dfsResult(data); return !!r && hasAnyKey(r, ['content', 'plain_text', 'title']) },
|
|
2717
|
+
},
|
|
2718
|
+
]
|
|
2719
|
+
|
|
2720
|
+
// ---------------------------------------------------------------------------
|
|
2721
|
+
// enrich_person
|
|
2722
|
+
// ---------------------------------------------------------------------------
|
|
2723
|
+
|
|
2724
|
+
const enrichPersonProviders: ProviderEntry[] = [
|
|
2725
|
+
{
|
|
2726
|
+
id: 'linkupapi-profile-enrich',
|
|
2727
|
+
endpoint: '/linkupapi/data/profil/enrich',
|
|
2728
|
+
method: 'POST',
|
|
2729
|
+
priority: 1,
|
|
2730
|
+
isApplicable: (input) =>
|
|
2731
|
+
!!input.first_name && !!input.last_name && !!(input.company_name || input.domain),
|
|
2732
|
+
mapParams: (input) => ({
|
|
2733
|
+
body: {
|
|
2734
|
+
first_name: input.first_name,
|
|
2735
|
+
last_name: input.last_name,
|
|
2736
|
+
company_name: input.company_name ?? input.domain,
|
|
2737
|
+
},
|
|
2738
|
+
}),
|
|
2739
|
+
// Both profile-enrich and email-reverse return { status: 'success', data: {...} }
|
|
2740
|
+
hasResult: (data) => {
|
|
2741
|
+
const d = data as Record<string, unknown>
|
|
2742
|
+
return d.status === 'success' && !!d.data
|
|
2743
|
+
},
|
|
2744
|
+
},
|
|
2745
|
+
{
|
|
2746
|
+
id: 'linkupapi-email-reverse',
|
|
2747
|
+
endpoint: '/linkupapi/data/mail/reverse',
|
|
2748
|
+
method: 'POST',
|
|
2749
|
+
priority: 2,
|
|
2750
|
+
isApplicable: (input) => typeof input.email === 'string' && (input.email as string).includes('@'),
|
|
2751
|
+
mapParams: (input) => ({ body: { email: input.email } }),
|
|
2752
|
+
hasResult: (data) => {
|
|
2753
|
+
const d = data as Record<string, unknown>
|
|
2754
|
+
return d.status === 'success' && !!d.data
|
|
2755
|
+
},
|
|
2756
|
+
},
|
|
2757
|
+
{
|
|
2758
|
+
id: 'pdl-person-enrich',
|
|
2759
|
+
endpoint: '/pdl/person/enrich',
|
|
2760
|
+
method: 'POST',
|
|
2761
|
+
priority: 3,
|
|
2762
|
+
mapParams: (input) => ({
|
|
2763
|
+
body: {
|
|
2764
|
+
email: input.email,
|
|
2765
|
+
profile: input.linkedin_url,
|
|
2766
|
+
phone: input.phone,
|
|
2767
|
+
first_name: input.first_name,
|
|
2768
|
+
last_name: input.last_name,
|
|
2769
|
+
company: input.company_name ?? input.domain,
|
|
2770
|
+
},
|
|
2771
|
+
}),
|
|
2772
|
+
// PDL route translates 404 (no match) into HTTP 200 with status:404 — check inner status
|
|
2773
|
+
hasResult: (data) => {
|
|
2774
|
+
const d = data as Record<string, unknown>
|
|
2775
|
+
return d.status === 200 && d.data !== null && d.data !== undefined
|
|
2776
|
+
},
|
|
2777
|
+
},
|
|
2778
|
+
{
|
|
2779
|
+
id: 'apollo-people-match',
|
|
2780
|
+
endpoint: '/apollo/people/match',
|
|
2781
|
+
method: 'POST',
|
|
2782
|
+
priority: 4,
|
|
2783
|
+
mapParams: (input) => ({
|
|
2784
|
+
body: {
|
|
2785
|
+
first_name: input.first_name,
|
|
2786
|
+
last_name: input.last_name,
|
|
2787
|
+
email: input.email,
|
|
2788
|
+
organization_name: input.company_name,
|
|
2789
|
+
domain: input.domain,
|
|
2790
|
+
linkedin_url: input.linkedin_url,
|
|
2791
|
+
},
|
|
2792
|
+
}),
|
|
2793
|
+
hasResult: (data) => {
|
|
2794
|
+
const d = data as Record<string, unknown>
|
|
2795
|
+
return !!d.person && typeof d.person === 'object'
|
|
2796
|
+
},
|
|
2797
|
+
},
|
|
2798
|
+
{
|
|
2799
|
+
id: 'blitzapi-reverse-email',
|
|
2800
|
+
endpoint: '/blitzapi/enrichment/reverse-email-lookup',
|
|
2801
|
+
method: 'POST',
|
|
2802
|
+
priority: 5,
|
|
2803
|
+
isApplicable: (input) => typeof input.email === 'string' && (input.email as string).includes('@'),
|
|
2804
|
+
mapParams: (input) => ({ body: { email: input.email } }),
|
|
2805
|
+
hasResult: (data) => {
|
|
2806
|
+
const d = data as Record<string, unknown>
|
|
2807
|
+
return hasAnyKey(d, ['person', 'profile', 'name', 'first_name', 'linkedin_url'])
|
|
2808
|
+
},
|
|
2809
|
+
},
|
|
2810
|
+
{
|
|
2811
|
+
id: 'findymail-business-profile',
|
|
2812
|
+
endpoint: '/findymail/search/business-profile',
|
|
2813
|
+
method: 'POST',
|
|
2814
|
+
priority: 6,
|
|
2815
|
+
isApplicable: (input) => typeof input.linkedin_url === 'string' && (input.linkedin_url as string).includes('linkedin.com'),
|
|
2816
|
+
mapParams: (input) => ({ body: { linkedin_url: input.linkedin_url } }),
|
|
2817
|
+
hasResult: (data) => {
|
|
2818
|
+
const d = data as Record<string, unknown>
|
|
2819
|
+
return !d.error && hasAnyKey(d, ['email', 'name', 'first_name', 'linkedin_url'])
|
|
2820
|
+
},
|
|
2821
|
+
},
|
|
2822
|
+
{
|
|
2823
|
+
id: 'findymail-reverse-email',
|
|
2824
|
+
endpoint: '/findymail/search/reverse-email',
|
|
2825
|
+
method: 'POST',
|
|
2826
|
+
priority: 7,
|
|
2827
|
+
isApplicable: (input) => typeof input.email === 'string' && (input.email as string).includes('@'),
|
|
2828
|
+
mapParams: (input) => ({ body: { email: input.email, with_profile: true } }),
|
|
2829
|
+
hasResult: (data) => {
|
|
2830
|
+
const d = data as Record<string, unknown>
|
|
2831
|
+
return !d.error && hasAnyKey(d, ['email', 'name', 'first_name', 'linkedin_url'])
|
|
2832
|
+
},
|
|
2833
|
+
},
|
|
2834
|
+
{
|
|
2835
|
+
id: 'icypeas-scrape-profile',
|
|
2836
|
+
endpoint: '/icypeas/scrape/profile',
|
|
2837
|
+
method: 'POST',
|
|
2838
|
+
priority: 8,
|
|
2839
|
+
isApplicable: (input) => typeof input.linkedin_url === 'string' && (input.linkedin_url as string).includes('linkedin.com'),
|
|
2840
|
+
// Icypeas expects the field named 'url'
|
|
2841
|
+
mapParams: (input) => ({ body: { url: input.linkedin_url } }),
|
|
2842
|
+
hasResult: (data) => {
|
|
2843
|
+
const d = data as Record<string, unknown>
|
|
2844
|
+
return d.success === true && !!d.profile
|
|
2845
|
+
},
|
|
2846
|
+
},
|
|
2847
|
+
{
|
|
2848
|
+
id: 'icypeas-url-search-profile',
|
|
2849
|
+
endpoint: '/icypeas/url-search/profile',
|
|
2850
|
+
method: 'POST',
|
|
2851
|
+
priority: 9,
|
|
2852
|
+
isApplicable: (input) =>
|
|
2853
|
+
!!input.first_name && !!input.last_name && !!(input.company_name || input.domain),
|
|
2854
|
+
mapParams: (input) => ({
|
|
2855
|
+
body: {
|
|
2856
|
+
firstname: input.first_name,
|
|
2857
|
+
lastname: input.last_name,
|
|
2858
|
+
companyOrDomain: input.company_name ?? input.domain,
|
|
2859
|
+
},
|
|
2860
|
+
}),
|
|
2861
|
+
hasResult: (data) => {
|
|
2862
|
+
const d = data as Record<string, unknown>
|
|
2863
|
+
return typeof d.profileUrl === 'string' && d.profileUrl.includes('linkedin.com')
|
|
2864
|
+
},
|
|
2865
|
+
},
|
|
2866
|
+
{
|
|
2867
|
+
id: 'ai-ark-reverse-lookup',
|
|
2868
|
+
endpoint: '/ai-ark/people/reverse-lookup',
|
|
2869
|
+
method: 'POST',
|
|
2870
|
+
priority: 10,
|
|
2871
|
+
isApplicable: (input) =>
|
|
2872
|
+
(typeof input.email === 'string' && (input.email as string).includes('@')) ||
|
|
2873
|
+
(typeof input.phone === 'string' && (input.phone as string).length > 5),
|
|
2874
|
+
// AI-Ark accepts a single `search` string (email or phone)
|
|
2875
|
+
mapParams: (input) => ({
|
|
2876
|
+
body: { search: (input.email ?? input.phone) as string },
|
|
2877
|
+
}),
|
|
2878
|
+
hasResult: (data) => {
|
|
2879
|
+
return hasAnyKey(data as Record<string, unknown>, ['name', 'first_name', 'email', 'linkedin_url'])
|
|
2880
|
+
},
|
|
2881
|
+
},
|
|
2882
|
+
{
|
|
2883
|
+
id: 'icypeas-reverse-email-lookup',
|
|
2884
|
+
endpoint: '/icypeas/reverse-email-lookup',
|
|
2885
|
+
method: 'POST',
|
|
2886
|
+
priority: 11,
|
|
2887
|
+
isApplicable: (input) => typeof input.email === 'string' && (input.email as string).includes('@'),
|
|
2888
|
+
mapParams: (input) => ({ body: { email: input.email } }),
|
|
2889
|
+
hasResult: (data) => {
|
|
2890
|
+
const d = data as Record<string, unknown>
|
|
2891
|
+
return isNonEmptyArray(d.profiles) || (d.success === true && !!d.data)
|
|
2892
|
+
},
|
|
2893
|
+
},
|
|
2894
|
+
{
|
|
2895
|
+
id: 'pdl-person-identify',
|
|
2896
|
+
endpoint: '/pdl/person/identify',
|
|
2897
|
+
method: 'POST',
|
|
2898
|
+
priority: 12,
|
|
2899
|
+
mapParams: (input) => ({
|
|
2900
|
+
body: {
|
|
2901
|
+
email: input.email,
|
|
2902
|
+
profile: input.linkedin_url,
|
|
2903
|
+
phone: input.phone,
|
|
2904
|
+
first_name: input.first_name,
|
|
2905
|
+
last_name: input.last_name,
|
|
2906
|
+
company: input.company_name ?? input.domain,
|
|
2907
|
+
},
|
|
2908
|
+
}),
|
|
2909
|
+
// PDL identify returns { status: 200, matches: [...], total: N }
|
|
2910
|
+
hasResult: (data) => {
|
|
2911
|
+
const d = data as Record<string, unknown>
|
|
2912
|
+
return d.status === 200 && isNonEmptyArray(d.matches)
|
|
2913
|
+
},
|
|
2914
|
+
},
|
|
2915
|
+
]
|
|
2916
|
+
|
|
2917
|
+
// ---------------------------------------------------------------------------
|
|
2918
|
+
// find_signals
|
|
2919
|
+
// ---------------------------------------------------------------------------
|
|
2920
|
+
|
|
2921
|
+
const findSignalsProviders: ProviderEntry[] = [
|
|
2922
|
+
{
|
|
2923
|
+
id: 'signalbase-funding',
|
|
2924
|
+
endpoint: '/signalbase/funding-signals',
|
|
2925
|
+
method: 'GET',
|
|
2926
|
+
priority: 1,
|
|
2927
|
+
isApplicable: (input) => input.signal_type === 'funding',
|
|
2928
|
+
mapParams: (input) => {
|
|
2929
|
+
const qp: Record<string, string> = {}
|
|
2930
|
+
if (isNonEmptyArray(input.companies)) qp.company_name = (input.companies as string[])[0]
|
|
2931
|
+
if (typeof input.since === 'string') qp.dateFrom = input.since
|
|
2932
|
+
if (isNonEmptyArray(input.industries)) qp.industry = (input.industries as string[]).join(',')
|
|
2933
|
+
if (isNonEmptyArray(input.countries)) qp.countries = (input.countries as string[]).join(',')
|
|
2934
|
+
qp.limit = String(Math.min((input.limit as number | undefined) ?? 25, 100))
|
|
2935
|
+
return { queryParams: qp }
|
|
2936
|
+
},
|
|
2937
|
+
// Known Signalbase data quality bugs handled here:
|
|
2938
|
+
// Bug 1: company* fields populated with investor data — fixed via slug-derived name.
|
|
2939
|
+
// Bug 2: stale articles (e.g. 2019) stamped with today's date and surfaced as new signals.
|
|
2940
|
+
// Signalbase copies occurredAt to all sources[].publishedAt so date comparison is
|
|
2941
|
+
// useless — we extract years embedded in news article URLs instead.
|
|
2942
|
+
// Bug 3: wrong companyCountry (e.g. UK company tagged as FR) — unfixable without geo lookup.
|
|
2943
|
+
postFilter: (data) => {
|
|
2944
|
+
const d = data as Record<string, unknown>
|
|
2945
|
+
const signals = (d.data as Array<Record<string, unknown>> | undefined) ?? []
|
|
2946
|
+
|
|
2947
|
+
d.data = signals
|
|
2948
|
+
.filter((signal) => {
|
|
2949
|
+
// Bug 2: drop signals where any news article URL embeds a year 5+ years before occurredAt.
|
|
2950
|
+
const oy = new Date(signal.occurredAt as string).getFullYear()
|
|
2951
|
+
if (isNaN(oy)) return true
|
|
2952
|
+
const sources = (signal.sources as Array<{ url?: string; sourceType?: string }> | undefined) ?? []
|
|
2953
|
+
const urlYears = sources
|
|
2954
|
+
.filter((s) => s.sourceType === 'news_article')
|
|
2955
|
+
.flatMap((s) => { const m = (s.url ?? '').match(/\/(20\d{2})[\/\-]/); return m ? [parseInt(m[1])] : [] })
|
|
2956
|
+
return urlYears.length === 0 || Math.min(...urlYears) > oy - 5
|
|
2957
|
+
})
|
|
2958
|
+
.map((signal) => {
|
|
2959
|
+
// Bug 1: fix when companyName matches an investor and companySlug points elsewhere.
|
|
2960
|
+
const companyName = signal.companyName as string | undefined
|
|
2961
|
+
const companySlug = signal.companySlug as string | undefined
|
|
2962
|
+
if (!companyName || !companySlug) return signal
|
|
2963
|
+
const slugName = companySlug.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
|
2964
|
+
if (slugName.toLowerCase() === companyName.toLowerCase()) return signal
|
|
2965
|
+
const investors = (signal.investors as Array<{ name?: string }> | undefined) ?? []
|
|
2966
|
+
if (!investors.some((inv) => inv.name?.toLowerCase() === companyName.toLowerCase())) return signal
|
|
2967
|
+
return {
|
|
2968
|
+
...signal,
|
|
2969
|
+
companyName: slugName,
|
|
2970
|
+
companyWebsite: null,
|
|
2971
|
+
companyLinkedin: null,
|
|
2972
|
+
companyLogo: null,
|
|
2973
|
+
companyDescription: null,
|
|
2974
|
+
}
|
|
2975
|
+
})
|
|
2976
|
+
|
|
2977
|
+
return d
|
|
2978
|
+
},
|
|
2979
|
+
hasResult: (data) => isNonEmptyArray((data as Record<string, unknown>).data),
|
|
2980
|
+
},
|
|
2981
|
+
{
|
|
2982
|
+
id: 'signalbase-acquisition',
|
|
2983
|
+
endpoint: '/signalbase/acquisition-signals',
|
|
2984
|
+
method: 'GET',
|
|
2985
|
+
priority: 2,
|
|
2986
|
+
isApplicable: (input) => input.signal_type === 'acquisition',
|
|
2987
|
+
mapParams: (input) => {
|
|
2988
|
+
const qp: Record<string, string> = {}
|
|
2989
|
+
if (isNonEmptyArray(input.companies)) qp.company_name = (input.companies as string[])[0]
|
|
2990
|
+
if (typeof input.since === 'string') qp.dateFrom = input.since
|
|
2991
|
+
if (isNonEmptyArray(input.industries)) qp.industry = (input.industries as string[]).join(',')
|
|
2992
|
+
if (isNonEmptyArray(input.countries)) qp.countries = (input.countries as string[]).join(',')
|
|
2993
|
+
qp.limit = String(Math.min((input.limit as number | undefined) ?? 25, 100))
|
|
2994
|
+
return { queryParams: qp }
|
|
2995
|
+
},
|
|
2996
|
+
hasResult: (data) => isNonEmptyArray((data as Record<string, unknown>).data),
|
|
2997
|
+
},
|
|
2998
|
+
{
|
|
2999
|
+
id: 'signalbase-hiring',
|
|
3000
|
+
endpoint: '/signalbase/hiring-signals',
|
|
3001
|
+
method: 'GET',
|
|
3002
|
+
priority: 3,
|
|
3003
|
+
isApplicable: (input) => input.signal_type === 'hiring',
|
|
3004
|
+
mapParams: (input) => {
|
|
3005
|
+
const qp: Record<string, string> = {}
|
|
3006
|
+
if (isNonEmptyArray(input.companies)) qp.search = (input.companies as string[])[0]
|
|
3007
|
+
if (typeof input.since === 'string') qp.dateFrom = input.since
|
|
3008
|
+
if (isNonEmptyArray(input.countries)) qp.countries = (input.countries as string[]).join(',')
|
|
3009
|
+
qp.limit = String(Math.min((input.limit as number | undefined) ?? 25, 100))
|
|
3010
|
+
return { queryParams: qp }
|
|
3011
|
+
},
|
|
3012
|
+
hasResult: (data) => isNonEmptyArray((data as Record<string, unknown>).data),
|
|
3013
|
+
},
|
|
3014
|
+
{
|
|
3015
|
+
id: 'signalbase-job-change',
|
|
3016
|
+
endpoint: '/signalbase/job-change-signals',
|
|
3017
|
+
method: 'GET',
|
|
3018
|
+
priority: 4,
|
|
3019
|
+
isApplicable: (input) => input.signal_type === 'job_change',
|
|
3020
|
+
mapParams: (input) => {
|
|
3021
|
+
const qp: Record<string, string> = {}
|
|
3022
|
+
if (isNonEmptyArray(input.companies)) qp.company_name = (input.companies as string[])[0]
|
|
3023
|
+
if (typeof input.since === 'string') qp.dateFrom = input.since
|
|
3024
|
+
if (isNonEmptyArray(input.countries)) qp.countries = (input.countries as string[]).join(',')
|
|
3025
|
+
qp.limit = String(Math.min((input.limit as number | undefined) ?? 25, 100))
|
|
3026
|
+
return { queryParams: qp }
|
|
3027
|
+
},
|
|
3028
|
+
hasResult: (data) => isNonEmptyArray((data as Record<string, unknown>).data),
|
|
3029
|
+
},
|
|
3030
|
+
{
|
|
3031
|
+
id: 'theirstack-buying-intents',
|
|
3032
|
+
endpoint: '/theirstack/companies/buying_intents',
|
|
3033
|
+
method: 'POST',
|
|
3034
|
+
priority: 5,
|
|
3035
|
+
isApplicable: (input) =>
|
|
3036
|
+
input.signal_type === 'intent' &&
|
|
3037
|
+
(isNonEmptyArray(input.companies) || isNonEmptyArray(input.domains)),
|
|
3038
|
+
mapParams: (input) => ({
|
|
3039
|
+
body: {
|
|
3040
|
+
...(isNonEmptyArray(input.companies) && { company_name_or: input.companies }),
|
|
3041
|
+
...(isNonEmptyArray(input.domains) && { company_domain: (input.domains as string[])[0] }),
|
|
3042
|
+
},
|
|
3043
|
+
}),
|
|
3044
|
+
hasResult: (data) => isNonEmptyArray((data as Record<string, unknown>).data),
|
|
3045
|
+
},
|
|
3046
|
+
{
|
|
3047
|
+
id: 'predictleads-financing',
|
|
3048
|
+
endpoint: '/predictleads/discover/financing_events',
|
|
3049
|
+
method: 'GET',
|
|
3050
|
+
priority: 6,
|
|
3051
|
+
// Fallback for 'funding' — fewer filters than signalbase-funding but broader coverage
|
|
3052
|
+
isApplicable: (input) => input.signal_type === 'funding',
|
|
3053
|
+
mapParams: (input) => {
|
|
3054
|
+
const qp: Record<string, string> = {}
|
|
3055
|
+
if (isNonEmptyArray(input.countries)) qp.company_location = (input.countries as string[])[0]
|
|
3056
|
+
qp.limit = String(Math.min((input.limit as number | undefined) ?? 25, 100))
|
|
3057
|
+
return { queryParams: qp }
|
|
3058
|
+
},
|
|
3059
|
+
hasResult: (data) => isNonEmptyArray((data as Record<string, unknown>).data),
|
|
3060
|
+
},
|
|
3061
|
+
{
|
|
3062
|
+
id: 'predictleads-news',
|
|
3063
|
+
endpoint: '/predictleads/discover/news_events',
|
|
3064
|
+
method: 'GET',
|
|
3065
|
+
priority: 7,
|
|
3066
|
+
isApplicable: (input) => input.signal_type === 'news',
|
|
3067
|
+
mapParams: (input) => {
|
|
3068
|
+
const qp: Record<string, string> = {}
|
|
3069
|
+
if (isNonEmptyArray(input.countries)) qp.company_location = (input.countries as string[])[0]
|
|
3070
|
+
qp.limit = String(Math.min((input.limit as number | undefined) ?? 25, 100))
|
|
3071
|
+
return { queryParams: qp }
|
|
3072
|
+
},
|
|
3073
|
+
hasResult: (data) => isNonEmptyArray((data as Record<string, unknown>).data),
|
|
3074
|
+
},
|
|
3075
|
+
{
|
|
3076
|
+
id: 'predictleads-startup-posts',
|
|
3077
|
+
endpoint: '/predictleads/discover/startup_platform_posts',
|
|
3078
|
+
method: 'GET',
|
|
3079
|
+
priority: 8,
|
|
3080
|
+
isApplicable: (input) => input.signal_type === 'startup_post',
|
|
3081
|
+
mapParams: (input) => {
|
|
3082
|
+
const qp: Record<string, string> = {}
|
|
3083
|
+
if (typeof input.since === 'string') qp.published_at_from = input.since
|
|
3084
|
+
qp.limit = String(Math.min((input.limit as number | undefined) ?? 25, 100))
|
|
3085
|
+
return { queryParams: qp }
|
|
3086
|
+
},
|
|
3087
|
+
hasResult: (data) => isNonEmptyArray((data as Record<string, unknown>).data),
|
|
3088
|
+
},
|
|
3089
|
+
]
|
|
3090
|
+
|
|
3091
|
+
// ---------------------------------------------------------------------------
|
|
3092
|
+
// fetch_page_content
|
|
3093
|
+
// ---------------------------------------------------------------------------
|
|
3094
|
+
|
|
3095
|
+
const fetchPageContentProviders: ProviderEntry[] = [
|
|
3096
|
+
{
|
|
3097
|
+
id: 'exa-contents',
|
|
3098
|
+
endpoint: '/exa/contents',
|
|
3099
|
+
method: 'POST',
|
|
3100
|
+
priority: 1,
|
|
3101
|
+
mapParams: (input) => ({
|
|
3102
|
+
body: {
|
|
3103
|
+
urls: input.urls,
|
|
3104
|
+
text: (input.include_text as boolean | undefined) !== false ? true : undefined,
|
|
3105
|
+
summary: (input.include_summary as boolean | undefined) === true ? {} : undefined,
|
|
3106
|
+
},
|
|
3107
|
+
}),
|
|
3108
|
+
hasResult: (data) => isNonEmptyArray((data as Record<string, unknown>).results),
|
|
3109
|
+
},
|
|
3110
|
+
]
|
|
3111
|
+
|
|
3112
|
+
// ---------------------------------------------------------------------------
|
|
3113
|
+
// Registry
|
|
3114
|
+
// ---------------------------------------------------------------------------
|
|
3115
|
+
|
|
3116
|
+
const registry: Record<Capability, ProviderEntry[]> = {
|
|
3117
|
+
search_companies: searchCompaniesProviders,
|
|
3118
|
+
find_people: findPeopleProviders,
|
|
3119
|
+
find_email: findEmailProviders,
|
|
3120
|
+
verify_email: verifyEmailProviders,
|
|
3121
|
+
find_phone: findPhoneProviders,
|
|
3122
|
+
enrich_company: enrichCompanyProviders,
|
|
3123
|
+
enrich_person: enrichPersonProviders,
|
|
3124
|
+
search_web: searchWebProviders,
|
|
3125
|
+
search_jobs: searchJobsProviders,
|
|
3126
|
+
search_ads: searchAdsProviders,
|
|
3127
|
+
search_places: searchPlacesProviders,
|
|
3128
|
+
find_influencers: findInfluencersProviders,
|
|
3129
|
+
search_reddit: searchRedditProviders,
|
|
3130
|
+
search_seo: searchSeoProviders,
|
|
3131
|
+
find_signals: findSignalsProviders,
|
|
3132
|
+
fetch_page_content: fetchPageContentProviders,
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
/**
|
|
3136
|
+
* Get the ordered provider list for a capability.
|
|
3137
|
+
* Returns a copy sorted by priority (lower = first).
|
|
3138
|
+
*/
|
|
3139
|
+
export function getProviders(capability: Capability): ProviderEntry[] {
|
|
3140
|
+
const providers = registry[capability]
|
|
3141
|
+
if (!providers) throw new Error(`Unknown capability: ${capability}`)
|
|
3142
|
+
return [...providers].sort((a, b) => a.priority - b.priority)
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
/**
|
|
3146
|
+
* Get providers for search_web, reordering for neural search preference.
|
|
3147
|
+
*/
|
|
3148
|
+
export function getSearchWebProviders(preferNeural: boolean): ProviderEntry[] {
|
|
3149
|
+
const providers = getProviders('search_web')
|
|
3150
|
+
if (preferNeural) {
|
|
3151
|
+
// Put Exa first when neural is requested
|
|
3152
|
+
return providers.sort((a, b) => {
|
|
3153
|
+
if (a.id === 'exa') return -1
|
|
3154
|
+
if (b.id === 'exa') return 1
|
|
3155
|
+
return a.priority - b.priority
|
|
3156
|
+
})
|
|
3157
|
+
}
|
|
3158
|
+
return providers
|
|
3159
|
+
}
|