@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.
Files changed (176) hide show
  1. package/dist/client.d.ts +8 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +47 -0
  4. package/dist/client.js.map +1 -0
  5. package/dist/executor.d.ts +21 -0
  6. package/dist/executor.d.ts.map +1 -0
  7. package/dist/executor.js +130 -0
  8. package/dist/executor.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +49 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/registry.d.ts +49 -0
  14. package/dist/registry.d.ts.map +1 -0
  15. package/dist/registry.js +3104 -0
  16. package/dist/registry.js.map +1 -0
  17. package/dist/tools/enrich-company.d.ts +22 -0
  18. package/dist/tools/enrich-company.d.ts.map +1 -0
  19. package/dist/tools/enrich-company.js +21 -0
  20. package/dist/tools/enrich-company.js.map +1 -0
  21. package/dist/tools/enrich-email.d.ts +24 -0
  22. package/dist/tools/enrich-email.d.ts.map +1 -0
  23. package/dist/tools/enrich-email.js +19 -0
  24. package/dist/tools/enrich-email.js.map +1 -0
  25. package/dist/tools/enrich-emails.d.ts +31 -0
  26. package/dist/tools/enrich-emails.d.ts.map +1 -0
  27. package/dist/tools/enrich-emails.js +146 -0
  28. package/dist/tools/enrich-emails.js.map +1 -0
  29. package/dist/tools/enrich-person.d.ts +26 -0
  30. package/dist/tools/enrich-person.d.ts.map +1 -0
  31. package/dist/tools/enrich-person.js +23 -0
  32. package/dist/tools/enrich-person.js.map +1 -0
  33. package/dist/tools/fetch-page-content.d.ts +22 -0
  34. package/dist/tools/fetch-page-content.d.ts.map +1 -0
  35. package/dist/tools/fetch-page-content.js +32 -0
  36. package/dist/tools/fetch-page-content.js.map +1 -0
  37. package/dist/tools/find-email.d.ts +24 -0
  38. package/dist/tools/find-email.d.ts.map +1 -0
  39. package/dist/tools/find-email.js +19 -0
  40. package/dist/tools/find-email.js.map +1 -0
  41. package/dist/tools/find-emails.d.ts +31 -0
  42. package/dist/tools/find-emails.d.ts.map +1 -0
  43. package/dist/tools/find-emails.js +146 -0
  44. package/dist/tools/find-emails.js.map +1 -0
  45. package/dist/tools/find-influencers.d.ts +29 -0
  46. package/dist/tools/find-influencers.d.ts.map +1 -0
  47. package/dist/tools/find-influencers.js +30 -0
  48. package/dist/tools/find-influencers.js.map +1 -0
  49. package/dist/tools/find-people.d.ts +26 -0
  50. package/dist/tools/find-people.d.ts.map +1 -0
  51. package/dist/tools/find-people.js +61 -0
  52. package/dist/tools/find-people.js.map +1 -0
  53. package/dist/tools/find-phone.d.ts +24 -0
  54. package/dist/tools/find-phone.d.ts.map +1 -0
  55. package/dist/tools/find-phone.js +48 -0
  56. package/dist/tools/find-phone.js.map +1 -0
  57. package/dist/tools/find-signals.d.ts +26 -0
  58. package/dist/tools/find-signals.d.ts.map +1 -0
  59. package/dist/tools/find-signals.js +82 -0
  60. package/dist/tools/find-signals.js.map +1 -0
  61. package/dist/tools/search-ads.d.ts +33 -0
  62. package/dist/tools/search-ads.d.ts.map +1 -0
  63. package/dist/tools/search-ads.js +33 -0
  64. package/dist/tools/search-ads.js.map +1 -0
  65. package/dist/tools/search-companies.d.ts +42 -0
  66. package/dist/tools/search-companies.d.ts.map +1 -0
  67. package/dist/tools/search-companies.js +37 -0
  68. package/dist/tools/search-companies.js.map +1 -0
  69. package/dist/tools/search-jobs.d.ts +51 -0
  70. package/dist/tools/search-jobs.d.ts.map +1 -0
  71. package/dist/tools/search-jobs.js +64 -0
  72. package/dist/tools/search-jobs.js.map +1 -0
  73. package/dist/tools/search-places.d.ts +47 -0
  74. package/dist/tools/search-places.d.ts.map +1 -0
  75. package/dist/tools/search-places.js +42 -0
  76. package/dist/tools/search-places.js.map +1 -0
  77. package/dist/tools/search-reddit.d.ts +27 -0
  78. package/dist/tools/search-reddit.d.ts.map +1 -0
  79. package/dist/tools/search-reddit.js +30 -0
  80. package/dist/tools/search-reddit.js.map +1 -0
  81. package/dist/tools/search-seo.d.ts +37 -0
  82. package/dist/tools/search-seo.d.ts.map +1 -0
  83. package/dist/tools/search-seo.js +49 -0
  84. package/dist/tools/search-seo.js.map +1 -0
  85. package/dist/tools/search-web.d.ts +23 -0
  86. package/dist/tools/search-web.d.ts.map +1 -0
  87. package/dist/tools/search-web.js +20 -0
  88. package/dist/tools/search-web.js.map +1 -0
  89. package/dist/tools/verify-email.d.ts +20 -0
  90. package/dist/tools/verify-email.d.ts.map +1 -0
  91. package/dist/tools/verify-email.js +15 -0
  92. package/dist/tools/verify-email.js.map +1 -0
  93. package/package.json +28 -0
  94. package/src/client.ts +60 -0
  95. package/src/executor.ts +182 -0
  96. package/src/index.ts +155 -0
  97. package/src/registry.ts +3159 -0
  98. package/src/tools/enrich-company.ts +25 -0
  99. package/src/tools/enrich-person.ts +27 -0
  100. package/src/tools/fetch-page-content.ts +36 -0
  101. package/src/tools/find-email.ts +23 -0
  102. package/src/tools/find-emails.ts +190 -0
  103. package/src/tools/find-influencers.ts +34 -0
  104. package/src/tools/find-people.ts +69 -0
  105. package/src/tools/find-phone.ts +53 -0
  106. package/src/tools/find-signals.ts +93 -0
  107. package/src/tools/search-ads.ts +44 -0
  108. package/src/tools/search-companies.ts +41 -0
  109. package/src/tools/search-jobs.ts +73 -0
  110. package/src/tools/search-places.ts +52 -0
  111. package/src/tools/search-reddit.ts +34 -0
  112. package/src/tools/search-seo.ts +59 -0
  113. package/src/tools/search-web.ts +24 -0
  114. package/src/tools/verify-email.ts +19 -0
  115. package/test-ads-live.ts +77 -0
  116. package/test-company-live.ts +91 -0
  117. package/test-email-live.ts +171 -0
  118. package/test-influencers-live.ts +66 -0
  119. package/test-jobs-live.ts +69 -0
  120. package/test-linkupapi-live.ts +137 -0
  121. package/test-phone-live.ts +41 -0
  122. package/test-places-live.ts +89 -0
  123. package/test-reddit-live.ts +66 -0
  124. package/test-search-live.ts +79 -0
  125. package/test-seo-live.ts +68 -0
  126. package/test-web-live.ts +67 -0
  127. package/tests/client.test.ts +90 -0
  128. package/tests/executor.test.ts +83 -0
  129. package/tests/gtm/01-icp-to-emails.test.ts +43 -0
  130. package/tests/gtm/02-icp-bulk-emails.test.ts +38 -0
  131. package/tests/gtm/03-icp-to-phones.test.ts +39 -0
  132. package/tests/gtm/04-funding-signal-outreach.test.ts +42 -0
  133. package/tests/gtm/05-hiring-signal-decisionmakers.test.ts +41 -0
  134. package/tests/gtm/06-intent-signal-outreach.test.ts +44 -0
  135. package/tests/gtm/07-places-to-content.test.ts +50 -0
  136. package/tests/gtm/08-domain-to-account.test.ts +44 -0
  137. package/tests/gtm/09-linkedin-to-everything.test.ts +41 -0
  138. package/tests/gtm/10-jobs-vs-signals-routing.test.ts +38 -0
  139. package/tests/gtm/11-find-vs-enrich-routing.test.ts +39 -0
  140. package/tests/gtm/12-bogus-domain-graceful.test.ts +42 -0
  141. package/tests/gtm/13-private-linkedin-graceful.test.ts +44 -0
  142. package/tests/gtm/14-empty-handoff.test.ts +43 -0
  143. package/tests/gtm/15-seo-reddit-research.test.ts +38 -0
  144. package/tests/gtm/README.md +59 -0
  145. package/tests/gtm/harness.ts +217 -0
  146. package/tests/gtm/tools-bridge.ts +232 -0
  147. package/tests/gtm-scenarios.md +32 -0
  148. package/tests/live/smoke-report.ts +255 -0
  149. package/tests/live/smoke.test.ts +134 -0
  150. package/tests/registry-enrich-person.test.ts +447 -0
  151. package/tests/registry-fetch-page-content.test.ts +90 -0
  152. package/tests/registry-find-people.test.ts +467 -0
  153. package/tests/registry-find-signals.test.ts +470 -0
  154. package/tests/registry-linkupapi.test.ts +331 -0
  155. package/tests/registry-search-companies.test.ts +188 -0
  156. package/tests/registry-search-jobs.test.ts +116 -0
  157. package/tests/registry.test.ts +2210 -0
  158. package/tests/tools/enrich-company.test.ts +92 -0
  159. package/tests/tools/enrich-email.test.ts +94 -0
  160. package/tests/tools/enrich-emails.test.ts +271 -0
  161. package/tests/tools/enrich-person.test.ts +140 -0
  162. package/tests/tools/fetch-page-content.test.ts +108 -0
  163. package/tests/tools/find-influencers.test.ts +91 -0
  164. package/tests/tools/find-people.test.ts +344 -0
  165. package/tests/tools/find-phone.test.ts +100 -0
  166. package/tests/tools/find-signals.test.ts +110 -0
  167. package/tests/tools/search-ads.test.ts +182 -0
  168. package/tests/tools/search-companies.test.ts +58 -0
  169. package/tests/tools/search-jobs.test.ts +210 -0
  170. package/tests/tools/search-places.test.ts +114 -0
  171. package/tests/tools/search-reddit.test.ts +125 -0
  172. package/tests/tools/search-seo.test.ts +183 -0
  173. package/tests/tools/search-web.test.ts +79 -0
  174. package/tests/tools/verify-email.test.ts +68 -0
  175. package/tsconfig.json +17 -0
  176. package/vitest.config.ts +7 -0
@@ -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
+ }