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