@coldiq/mcp 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/registry.ts +72 -6
- package/src/tools/find-emails.ts +64 -0
- package/src/tools/find-people.ts +1 -1
- package/tests/live/companyenrich-async-probe.ts +31 -0
- package/tests/live/companyenrich-fix-validation.ts +125 -0
- package/tests/live/companyenrich-hybrid-probe.ts +54 -0
- package/tests/live/companyenrich-query-probe.ts +49 -0
- package/tests/live/companyenrich-ranges-probe.ts +49 -0
- package/tests/live/companyenrich-upstream-probe.ts +72 -0
- package/tests/live/wework-brazil-trace.ts +200 -0
- package/tests/registry-find-people.test.ts +111 -0
- package/tests/registry.test.ts +26 -9
- package/tests/tools/find-emails.test.ts +53 -0
package/package.json
CHANGED
package/src/registry.ts
CHANGED
|
@@ -109,6 +109,60 @@ export function isoCountryToName(code: string): string {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// Reverse lookup: English country name → ISO 3166-1 alpha-2. Built lazily on first use.
|
|
113
|
+
// Agents routinely pass "Brazil" / "United States" instead of "BR" / "US" — without this
|
|
114
|
+
// coercion, downstream providers that require ISO codes (LeadsFactory, BlitzAPI) silently
|
|
115
|
+
// skip the filter.
|
|
116
|
+
let _nameToCode: Map<string, string> | undefined
|
|
117
|
+
function buildNameToCode(): Map<string, string> {
|
|
118
|
+
const map = new Map<string, string>()
|
|
119
|
+
// Iterate all ISO-2 codes A-Z × A-Z; keep those Intl.DisplayNames resolves.
|
|
120
|
+
for (let a = 65; a <= 90; a++) {
|
|
121
|
+
for (let b = 65; b <= 90; b++) {
|
|
122
|
+
const code = String.fromCharCode(a, b)
|
|
123
|
+
try {
|
|
124
|
+
const name = _displayNames.of(code)
|
|
125
|
+
if (name && name !== code) map.set(name.toLowerCase(), code)
|
|
126
|
+
} catch { /* skip */ }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Common aliases not covered by Intl.DisplayNames defaults.
|
|
130
|
+
map.set('usa', 'US')
|
|
131
|
+
map.set('uk', 'GB')
|
|
132
|
+
map.set('south korea', 'KR')
|
|
133
|
+
map.set('north korea', 'KP')
|
|
134
|
+
map.set('russia', 'RU')
|
|
135
|
+
map.set('iran', 'IR')
|
|
136
|
+
map.set('syria', 'SY')
|
|
137
|
+
map.set('uae', 'AE')
|
|
138
|
+
map.set('emirates', 'AE')
|
|
139
|
+
return map
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function nameToIsoCountry(input: string): string | undefined {
|
|
143
|
+
if (typeof input !== 'string' || input.length === 0) return undefined
|
|
144
|
+
if (!_nameToCode) _nameToCode = buildNameToCode()
|
|
145
|
+
// Check aliases first — "UK" matches the ISO-2 regex but is not a valid code (GB is).
|
|
146
|
+
const aliased = _nameToCode.get(input.trim().toLowerCase())
|
|
147
|
+
if (aliased) return aliased
|
|
148
|
+
if (_ALPHA2_RE.test(input)) {
|
|
149
|
+
const upper = input.toUpperCase()
|
|
150
|
+
// Only accept if Intl actually resolves it — rejects "ZZ" etc.
|
|
151
|
+
try {
|
|
152
|
+
if (_displayNames.of(upper)) return upper
|
|
153
|
+
} catch { /* fall through */ }
|
|
154
|
+
}
|
|
155
|
+
return undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Coerce a list of strings (mix of ISO-2 codes and country names) into ISO-2 codes.
|
|
159
|
+
// Unrecognized inputs are dropped (rather than passed through) so providers don't
|
|
160
|
+
// receive garbage like { code: "Brazil", name: "Brazil" }.
|
|
161
|
+
export function coerceCountriesToIso(values: readonly string[] | undefined): string[] {
|
|
162
|
+
if (!values?.length) return []
|
|
163
|
+
return values.map((v) => nameToIsoCountry(v)).filter((c): c is string => typeof c === 'string')
|
|
164
|
+
}
|
|
165
|
+
|
|
112
166
|
// ---------------------------------------------------------------------------
|
|
113
167
|
// Strict post-filter helpers
|
|
114
168
|
// ---------------------------------------------------------------------------
|
|
@@ -200,9 +254,12 @@ const searchCompaniesProviders: ProviderEntry[] = [
|
|
|
200
254
|
method: 'POST',
|
|
201
255
|
priority: 1,
|
|
202
256
|
mapParams: (input) => {
|
|
203
|
-
// CompanyEnrich
|
|
204
|
-
//
|
|
205
|
-
|
|
257
|
+
// CompanyEnrich's `keywords` body field is a tag filter that does not match
|
|
258
|
+
// brand names — sending "wework" returns global defaults instead of WeWork.
|
|
259
|
+
// Route free-text input through `query` (full-text on company name + domain).
|
|
260
|
+
// Industries are folded into the same string because CompanyEnrich's `industries`
|
|
261
|
+
// field requires exact taxonomy matches and rejects free-text like "SaaS".
|
|
262
|
+
const queryTerms = [
|
|
206
263
|
...((input.keywords as string[] | undefined) ?? []),
|
|
207
264
|
...((input.industries as string[] | undefined) ?? []),
|
|
208
265
|
]
|
|
@@ -213,7 +270,7 @@ const searchCompaniesProviders: ProviderEntry[] = [
|
|
|
213
270
|
return {
|
|
214
271
|
body: {
|
|
215
272
|
countries: input.countries,
|
|
216
|
-
|
|
273
|
+
query: queryTerms.length > 0 ? queryTerms.join(' ') : undefined,
|
|
217
274
|
technologies: isNonEmptyArray(input.technologies) ? input.technologies : undefined,
|
|
218
275
|
employees:
|
|
219
276
|
input.min_employees || input.max_employees
|
|
@@ -944,6 +1001,14 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
944
1001
|
? [...groups.values()].map((titles) => ({ job_title: titles.join(', ') }))
|
|
945
1002
|
: [{ job_title: 'Decision Maker' }]
|
|
946
1003
|
const hasLinkedInUrls = isNonEmptyArray(input.company_linkedin_urls)
|
|
1004
|
+
// LF natively scopes contacts by person location via country_codes. Coerce
|
|
1005
|
+
// input.locations (which agents pass as a mix of ISO-2 codes "BR" and country
|
|
1006
|
+
// names "Brazil") to ISO-2 first — LF rejects/ignores anything else and silently
|
|
1007
|
+
// returns unfiltered global results.
|
|
1008
|
+
const isoCodes = coerceCountriesToIso(input.locations as string[] | undefined)
|
|
1009
|
+
const country_codes = isoCodes.length > 0
|
|
1010
|
+
? isoCodes.map((code) => ({ code, name: isoCountryToName(code) }))
|
|
1011
|
+
: undefined
|
|
947
1012
|
return {
|
|
948
1013
|
body: {
|
|
949
1014
|
...(hasLinkedInUrls && { company_linkedin_urls: input.company_linkedin_urls }),
|
|
@@ -954,6 +1019,7 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
954
1019
|
search: {
|
|
955
1020
|
max_persona_results: (input.limit as number) ?? 25,
|
|
956
1021
|
personas,
|
|
1022
|
+
...(country_codes && { country_codes }),
|
|
957
1023
|
},
|
|
958
1024
|
},
|
|
959
1025
|
}
|
|
@@ -962,7 +1028,7 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
962
1028
|
const d = data as Record<string, unknown>
|
|
963
1029
|
return (
|
|
964
1030
|
isNonEmptyArray(d.companies_personas) ||
|
|
965
|
-
(d.status === 'SUCCESSFUL' &&
|
|
1031
|
+
(d.status === 'SUCCESSFUL' && d.nb_jobs_complete != null && (d.nb_jobs_complete as number) > 0)
|
|
966
1032
|
)
|
|
967
1033
|
},
|
|
968
1034
|
async: {
|
|
@@ -975,7 +1041,7 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
975
1041
|
isComplete: (data) => {
|
|
976
1042
|
const d = data as Record<string, unknown>
|
|
977
1043
|
const status = d.status as string | undefined
|
|
978
|
-
return status === 'SUCCESSFUL' || status === 'FAILED'
|
|
1044
|
+
return status === 'SUCCESSFUL' || status === 'FAILED' || status === 'INVALID_URL'
|
|
979
1045
|
},
|
|
980
1046
|
extractId: (response) => {
|
|
981
1047
|
const d = response as Record<string, unknown>
|
package/src/tools/find-emails.ts
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { callApi } from '../client.js'
|
|
3
|
+
import { executeWithFallback, isExecutionError } from '../executor.js'
|
|
3
4
|
import { resolvePreferredProviders, buildAllFailedError, FIND_EMAILS_PROVIDERS } from '../utils/provider-resolver.js'
|
|
4
5
|
|
|
6
|
+
// Single-find_email registry providers that the bulk pipeline does NOT already cover.
|
|
7
|
+
// Used as a fallback waterfall for residual misses after Prospeo + FullEnrich + Findymail + Icypeas.
|
|
8
|
+
const SINGLE_ONLY_FIND_EMAIL_PROVIDERS = ['limadata-work-email', 'blitzapi', 'limadata-work-email-linkedin', 'linkupapi'] as const
|
|
9
|
+
|
|
10
|
+
// Pulls a string email from any of the provider-specific response shapes used in the
|
|
11
|
+
// find_email registry (registry.ts:1228-1413). Keep in sync with the providers covered there.
|
|
12
|
+
function extractEmail(data: unknown): string | null {
|
|
13
|
+
if (!data || typeof data !== 'object') return null
|
|
14
|
+
const d = data as Record<string, unknown>
|
|
15
|
+
if (typeof d.email === 'string' && d.email.includes('@')) return d.email
|
|
16
|
+
if (Array.isArray(d.emails) && typeof d.emails[0] === 'string' && (d.emails[0] as string).includes('@')) return d.emails[0] as string
|
|
17
|
+
const person = d.person as Record<string, unknown> | undefined
|
|
18
|
+
const personEmail = person?.email as Record<string, unknown> | undefined
|
|
19
|
+
if (typeof personEmail?.email === 'string' && (personEmail.email as string).includes('@')) return personEmail.email as string
|
|
20
|
+
const nested = d.data
|
|
21
|
+
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
|
22
|
+
const inner = (nested as Record<string, unknown>).email
|
|
23
|
+
if (typeof inner === 'string' && inner.includes('@')) return inner
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(nested) && nested[0] && typeof nested[0] === 'object') {
|
|
26
|
+
const first = nested[0] as Record<string, unknown>
|
|
27
|
+
if (Array.isArray(first.emails) && typeof first.emails[0] === 'string' && (first.emails[0] as string).includes('@')) {
|
|
28
|
+
return first.emails[0] as string
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
5
34
|
export const findEmailsName = 'find_emails'
|
|
6
35
|
|
|
7
36
|
export const findEmailsDescription =
|
|
@@ -221,6 +250,41 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
|
|
|
221
250
|
if (steps.length > 0) await Promise.allSettled(steps)
|
|
222
251
|
}
|
|
223
252
|
|
|
253
|
+
// Step 4 — single-find_email fallback for stragglers.
|
|
254
|
+
// Bulk covers Prospeo + FullEnrich + FindyMail + IcyPeas. The single-find_email registry
|
|
255
|
+
// (registry.ts:1228-1413) additionally covers LimaData, BlitzAPI, LimaData-LinkedIn, LinkupAPI.
|
|
256
|
+
// Without this step, agents would observe bulk return 0 and fall back to N+1 single calls.
|
|
257
|
+
const stragglers = missesOf(people, results)
|
|
258
|
+
if (stragglers.length > 0) {
|
|
259
|
+
const fallbackProviders = isConstrained
|
|
260
|
+
? allowedProviders.filter((p) => (SINGLE_ONLY_FIND_EMAIL_PROVIDERS as readonly string[]).includes(p))
|
|
261
|
+
: [...SINGLE_ONLY_FIND_EMAIL_PROVIDERS]
|
|
262
|
+
if (fallbackProviders.length > 0) {
|
|
263
|
+
await Promise.all(
|
|
264
|
+
stragglers.map(async (person) => {
|
|
265
|
+
const hit = results.find((r) => r.id === person.id)
|
|
266
|
+
if (!hit || hit.email) return
|
|
267
|
+
const singleInput: Record<string, unknown> = {
|
|
268
|
+
first_name: person.first_name,
|
|
269
|
+
last_name: person.last_name,
|
|
270
|
+
domain: person.domain,
|
|
271
|
+
linkedin_url: person.linkedin_url,
|
|
272
|
+
}
|
|
273
|
+
// Per-person 30s ceiling so one slow provider doesn't block the rest.
|
|
274
|
+
const exec = executeWithFallback('find_email', singleInput, { providers: fallbackProviders })
|
|
275
|
+
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 30_000))
|
|
276
|
+
const raced = await Promise.race([exec, timeout])
|
|
277
|
+
if (!raced || isExecutionError(raced)) return
|
|
278
|
+
const email = extractEmail(raced.data)
|
|
279
|
+
if (email && !hit.email) {
|
|
280
|
+
hit.email = email
|
|
281
|
+
hit.provider = raced._meta.provider
|
|
282
|
+
}
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
224
288
|
const found = results.filter((r) => r.email !== null).length
|
|
225
289
|
|
|
226
290
|
if (isConstrained && found === 0) {
|
package/src/tools/find-people.ts
CHANGED
|
@@ -22,7 +22,7 @@ export const findPeopleSchema = {
|
|
|
22
22
|
company_domains: z.array(z.string()).optional().describe('Company domains to search (e.g. ["microsoft.com", "google.com"]). Use only when LinkedIn URLs are not available.'),
|
|
23
23
|
job_titles: z.array(z.string()).optional().describe('Target job titles (e.g. ["CEO", "VP of Sales"])'),
|
|
24
24
|
seniorities: z.array(z.string()).optional().describe('Seniority levels (e.g. ["c_suite", "vp", "director"])'),
|
|
25
|
-
locations: z.array(z.string()).optional().describe('Person or
|
|
25
|
+
locations: z.array(z.string()).optional().describe('Person locations as either ISO-2 country codes ("BR") or English country names ("Brazil") — both are accepted and normalized internally. LeadsFactory uses these to scope contacts to the country; other providers fall back to free-text matching.'),
|
|
26
26
|
keywords: z.array(z.string()).optional().describe('Free-text keyword search terms (e.g. ["growth", "AI"])'),
|
|
27
27
|
limit: z.number().min(1).max(500).default(25).describe('Max results across all companies combined (default: 25, max: 500). For multiple companies multiply: e.g. 10 companies × 5 each = 50.'),
|
|
28
28
|
use_providers: z.array(z.string()).optional().describe(`Optional ordered list of providers to use. Leave empty to let ColdIQ automatically pick the best tool for your inputs — recommended for most use cases. Available providers: ${getProvidersForCapability('find_people').join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Probe /companies/search/async body shape — does it want filters flat under `search`
|
|
2
|
+
// or still wrapped under `search.filters`?
|
|
3
|
+
const KEY = process.env.COMPANYENRICH_API_KEY!
|
|
4
|
+
const BASE = 'https://api.companyenrich.com'
|
|
5
|
+
|
|
6
|
+
async function call(label: string, body: unknown) {
|
|
7
|
+
const t0 = Date.now()
|
|
8
|
+
const res = await fetch(`${BASE}/companies/search/async`, {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
|
|
11
|
+
body: JSON.stringify(body),
|
|
12
|
+
signal: AbortSignal.timeout(20_000),
|
|
13
|
+
})
|
|
14
|
+
let data: any
|
|
15
|
+
try { data = await res.json() } catch { data = '<non-json>' }
|
|
16
|
+
console.log(`[${label}]\n body=${JSON.stringify(body)}\n status=${res.status} ms=${Date.now() - t0} data=${JSON.stringify(data).slice(0, 300)}\n`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
// Use count=1 to minimise credit burn.
|
|
21
|
+
await call('A: search.filters wrapped (current)', {
|
|
22
|
+
search: { filters: { query: 'wework' } }, count: 1,
|
|
23
|
+
})
|
|
24
|
+
await call('B: search flat (proposed)', {
|
|
25
|
+
search: { query: 'wework' }, count: 1,
|
|
26
|
+
})
|
|
27
|
+
// Some async APIs accept query at the very top too.
|
|
28
|
+
await call('C: query at top, no search envelope', { query: 'wework', count: 1 })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
main()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Validation suite — confirms the fix direction before committing.
|
|
2
|
+
//
|
|
3
|
+
// What we need to prove:
|
|
4
|
+
// 1. `{filters: {...}}` wrapping is broken across the FOUR search endpoints
|
|
5
|
+
// (search, preview, count, scroll). If only `/search` is affected, the fix is narrower.
|
|
6
|
+
// 2. Top-level shape works consistently — filter combinations (query + country,
|
|
7
|
+
// query + employees, etc.) all narrow the result set as expected.
|
|
8
|
+
// 3. The bulk-enrich endpoint is unaffected (the bug is search-specific).
|
|
9
|
+
// 4. Ranges that wrapFilters translates (employees, foundedYear) still need their
|
|
10
|
+
// translation logic — confirm whether upstream wants employeesFrom/employeesTo top-level
|
|
11
|
+
// or {employees: {from, to}} or [{from, to}].
|
|
12
|
+
//
|
|
13
|
+
// Run: COMPANYENRICH_API_KEY=… npx tsx mcp/tests/live/companyenrich-fix-validation.ts
|
|
14
|
+
|
|
15
|
+
const KEY = process.env.COMPANYENRICH_API_KEY
|
|
16
|
+
if (!KEY) { console.error('COMPANYENRICH_API_KEY required'); process.exit(1) }
|
|
17
|
+
const BASE = 'https://api.companyenrich.com'
|
|
18
|
+
|
|
19
|
+
async function callRaw(path: string, body: unknown) {
|
|
20
|
+
const t0 = Date.now()
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
signal: AbortSignal.timeout(30_000),
|
|
27
|
+
})
|
|
28
|
+
let data: any
|
|
29
|
+
try { data = await res.json() } catch { data = '<non-json>' }
|
|
30
|
+
return { ok: res.ok, status: res.status, ms: Date.now() - t0, data }
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return { ok: false, status: 0, ms: Date.now() - t0, data: { error: String(err) } }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function summarize(r: any) {
|
|
37
|
+
const items = (r.data?.items ?? []).slice(0, 3).map((c: any) => `${c.name}/${c.country ?? '?'}`)
|
|
38
|
+
return `status=${r.status} ms=${r.ms} count=${r.data?.items?.length ?? r.data?.count ?? '?'} top=${JSON.stringify(items)}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function header(s: string) { console.log(`\n${'═'.repeat(78)}\n${s}\n${'═'.repeat(78)}`) }
|
|
42
|
+
function sub(s: string) { console.log(`\n— ${s}`) }
|
|
43
|
+
|
|
44
|
+
async function main() {
|
|
45
|
+
// ============================================================================
|
|
46
|
+
header('PART 1 — Confirm the wrap-vs-top-level bug on EACH of 4 search endpoints')
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
const wrapped = { pageSize: 5, filters: { query: 'wework' } }
|
|
50
|
+
const flat = { pageSize: 5, query: 'wework' }
|
|
51
|
+
|
|
52
|
+
for (const path of ['/companies/search', '/companies/search/preview', '/companies/search/scroll']) {
|
|
53
|
+
sub(`${path} wrapped (current)`)
|
|
54
|
+
console.log(' ', summarize(await callRaw(path, wrapped)))
|
|
55
|
+
sub(`${path} flat (proposed)`)
|
|
56
|
+
console.log(' ', summarize(await callRaw(path, flat)))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// count endpoint returns a count not items
|
|
60
|
+
sub('/companies/search/count wrapped (current)')
|
|
61
|
+
let r = await callRaw('/companies/search/count', { filters: { query: 'wework' } })
|
|
62
|
+
console.log(` status=${r.status} ms=${r.ms} data=${JSON.stringify(r.data).slice(0, 200)}`)
|
|
63
|
+
sub('/companies/search/count flat (proposed)')
|
|
64
|
+
r = await callRaw('/companies/search/count', { query: 'wework' })
|
|
65
|
+
console.log(` status=${r.status} ms=${r.ms} data=${JSON.stringify(r.data).slice(0, 200)}`)
|
|
66
|
+
|
|
67
|
+
// ============================================================================
|
|
68
|
+
header('PART 2 — Confirm flat-shape combinations actually narrow results')
|
|
69
|
+
// ============================================================================
|
|
70
|
+
|
|
71
|
+
sub('flat: query="wework" + countries=["BR"]')
|
|
72
|
+
console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, query: 'wework', countries: ['BR'] })))
|
|
73
|
+
|
|
74
|
+
sub('flat: query="wework" + countries=["US"]')
|
|
75
|
+
console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, query: 'wework', countries: ['US'] })))
|
|
76
|
+
|
|
77
|
+
sub('flat: countries=["BR"] + keywords=["fintech"]')
|
|
78
|
+
console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, countries: ['BR'], keywords: ['fintech'] })))
|
|
79
|
+
|
|
80
|
+
sub('flat: keywords=["SaaS"] (does keywords field do anything at top level?)')
|
|
81
|
+
console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, keywords: ['SaaS'] })))
|
|
82
|
+
|
|
83
|
+
sub('flat: industries=["Software"] (does industries field do anything?)')
|
|
84
|
+
console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, industries: ['Software'] })))
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
header('PART 3 — Confirm range shape (employees, foundedYear) works at top level')
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
sub('flat: employees=[{from:1000, to:5000}]')
|
|
91
|
+
console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, employees: [{ from: 1000, to: 5000 }] })))
|
|
92
|
+
|
|
93
|
+
sub('flat: employeesFrom=1000 + employeesTo=5000 (the wrapFilters translation target)')
|
|
94
|
+
console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, employeesFrom: 1000, employeesTo: 5000 })))
|
|
95
|
+
|
|
96
|
+
sub('flat: foundedYear={from:2015, to:2020}')
|
|
97
|
+
console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, foundedYear: { from: 2015, to: 2020 } })))
|
|
98
|
+
|
|
99
|
+
sub('flat: foundedFrom=2015 + foundedTo=2020 (translated form)')
|
|
100
|
+
console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, foundedFrom: 2015, foundedTo: 2020 })))
|
|
101
|
+
|
|
102
|
+
// ============================================================================
|
|
103
|
+
header('PART 4 — Confirm bulk-enrich endpoint is unaffected (no wrapFilters there)')
|
|
104
|
+
// ============================================================================
|
|
105
|
+
|
|
106
|
+
sub('POST /companies/enrich?domain=wework.com (single enrich, GET-like)')
|
|
107
|
+
// Single enrich uses query params, not wrapFilters — should be unaffected.
|
|
108
|
+
try {
|
|
109
|
+
const er = await fetch(`${BASE}/companies/enrich?domain=wework.com`, {
|
|
110
|
+
method: 'GET',
|
|
111
|
+
headers: { Authorization: `Bearer ${KEY}` },
|
|
112
|
+
signal: AbortSignal.timeout(20_000),
|
|
113
|
+
})
|
|
114
|
+
const ed = await er.json()
|
|
115
|
+
console.log(` status=${er.status} name=${(ed as any)?.name} domain=${(ed as any)?.domain}`)
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.log(` failed: ${err}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
header('DONE')
|
|
122
|
+
// ============================================================================
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
main().catch((e) => { console.error('FATAL', e); process.exit(1) })
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Hybrid: scalars top-level, ranges wrapped? Or ranges with a special shape?
|
|
2
|
+
const KEY = process.env.COMPANYENRICH_API_KEY!
|
|
3
|
+
const BASE = 'https://api.companyenrich.com'
|
|
4
|
+
|
|
5
|
+
async function count(label: string, body: unknown) {
|
|
6
|
+
const t0 = Date.now()
|
|
7
|
+
const res = await fetch(`${BASE}/companies/search/count`, {
|
|
8
|
+
method: 'POST',
|
|
9
|
+
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
|
|
10
|
+
body: JSON.stringify(body),
|
|
11
|
+
signal: AbortSignal.timeout(20_000),
|
|
12
|
+
})
|
|
13
|
+
const data: any = await res.json().catch(() => ({}))
|
|
14
|
+
console.log(`[${label}]\n body=${JSON.stringify(body)}\n status=${res.status} ms=${Date.now() - t0} count=${data?.count} total=${data?.total}\n`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
// Hypothesis A: maybe ranges need `filters: {}` wrapping (hybrid model)
|
|
19
|
+
await count('A1: countries top-level + filters.employees', {
|
|
20
|
+
countries: ['US'], filters: { employees: { from: 1000, to: 5000 } },
|
|
21
|
+
})
|
|
22
|
+
await count('A2: countries top-level + filters.employeesFrom/To', {
|
|
23
|
+
countries: ['US'], filters: { employeesFrom: 1000, employeesTo: 5000 },
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Hypothesis B: everything inside filters (back to old shape)
|
|
27
|
+
await count('B: filters.countries + filters.employees array', {
|
|
28
|
+
filters: { countries: ['US'], employees: [{ from: 1000, to: 5000 }] },
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Hypothesis C: maybe a different field name (size, headcount_range, employee_range)
|
|
32
|
+
await count('C1: size {from,to}', { size: { from: 1000, to: 5000 } })
|
|
33
|
+
await count('C2: headcountRange {min,max}', { headcountRange: { min: 1000, max: 5000 } })
|
|
34
|
+
await count('C3: employeesRange', { employeesRange: { from: 1000, to: 5000 } })
|
|
35
|
+
|
|
36
|
+
// Hypothesis D: maybe array of buckets like ["1001-5000"] or letter codes
|
|
37
|
+
await count('D1: employees ["1001-5000"]', { employees: ['1001-5000'] })
|
|
38
|
+
await count('D2: employees ["F"]', { employees: ['F'] }) // LimaData uses letter buckets, just in case
|
|
39
|
+
|
|
40
|
+
// Hypothesis E: snake_case field name
|
|
41
|
+
await count('E: employees_from/employees_to', { employees_from: 1000, employees_to: 5000 })
|
|
42
|
+
|
|
43
|
+
// Hypothesis F: revenue & funding to triangulate — do ANY ranges work?
|
|
44
|
+
await count('F1: revenueFrom/revenueTo', { revenueFrom: 1000000, revenueTo: 10000000 })
|
|
45
|
+
await count('F2: filters.revenueFrom/revenueTo', { filters: { revenueFrom: 1000000, revenueTo: 10000000 } })
|
|
46
|
+
|
|
47
|
+
// Hypothesis G: lookup an actual reference-data endpoint for valid bucket IDs (CompanyEnrich does have one)
|
|
48
|
+
// Skipping — adds latency; do if all of the above fail.
|
|
49
|
+
|
|
50
|
+
// Sanity check
|
|
51
|
+
await count('sanity: countries=DE alone', { countries: ['DE'] })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Probe CompanyEnrich /companies/search to find the correct field name for brand-name lookup.
|
|
2
|
+
// We hit our marketplace endpoint with different request shapes and observe what comes back.
|
|
3
|
+
const API_URL = process.env.COLDIQ_API_URL ?? 'https://api.coldiq.com'
|
|
4
|
+
const API_KEY = process.env.COLDIQ_API_KEY!
|
|
5
|
+
|
|
6
|
+
async function call(body: unknown) {
|
|
7
|
+
const t0 = Date.now()
|
|
8
|
+
const res = await fetch(`${API_URL}/v1/companyenrich/companies/search`, {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
|
|
11
|
+
body: JSON.stringify(body),
|
|
12
|
+
signal: AbortSignal.timeout(30_000),
|
|
13
|
+
})
|
|
14
|
+
let data: any
|
|
15
|
+
try { data = await res.json() } catch { data = '<non-json>' }
|
|
16
|
+
return { ok: res.ok, status: res.status, ms: Date.now() - t0, data }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function summarize(r: any) {
|
|
20
|
+
const items = (r.data?.items ?? []).slice(0, 5).map((c: any) => `${c.name} (${c.domain})`)
|
|
21
|
+
return `status=${r.status} ms=${r.ms} items=${items.length} top5=${JSON.stringify(items)}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
console.log('=== Probe: how to find WeWork in CompanyEnrich ===\n')
|
|
26
|
+
|
|
27
|
+
console.log('1) query="wework"')
|
|
28
|
+
console.log(' ', summarize(await call({ query: 'wework', pageSize: 5 })))
|
|
29
|
+
|
|
30
|
+
console.log('\n2) names=["WeWork"]')
|
|
31
|
+
console.log(' ', summarize(await call({ names: ['WeWork'], pageSize: 5 })))
|
|
32
|
+
|
|
33
|
+
console.log('\n3) domains=["wework.com"]')
|
|
34
|
+
console.log(' ', summarize(await call({ domains: ['wework.com'], pageSize: 5 })))
|
|
35
|
+
|
|
36
|
+
console.log('\n4) query="wework" + countries=["US"]')
|
|
37
|
+
console.log(' ', summarize(await call({ query: 'wework', countries: ['US'], pageSize: 5 })))
|
|
38
|
+
|
|
39
|
+
console.log('\n5) semanticQuery="WeWork coworking"')
|
|
40
|
+
console.log(' ', summarize(await call({ semanticQuery: 'WeWork coworking', pageSize: 5 })))
|
|
41
|
+
|
|
42
|
+
console.log('\n6) names=["wework"] (lowercase)')
|
|
43
|
+
console.log(' ', summarize(await call({ names: ['wework'], pageSize: 5 })))
|
|
44
|
+
|
|
45
|
+
console.log('\n7) only countries=["BR"] (baseline — what does no name filter return?)')
|
|
46
|
+
console.log(' ', summarize(await call({ countries: ['BR'], pageSize: 5 })))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
main().catch(console.error)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Probe the range-filter shape. We use /companies/search/count because it gives a
|
|
2
|
+
// definitive "did the filter narrow?" signal — full-DB count is ~20.4M, so anything
|
|
3
|
+
// less means the filter applied.
|
|
4
|
+
const KEY = process.env.COMPANYENRICH_API_KEY!
|
|
5
|
+
const BASE = 'https://api.companyenrich.com'
|
|
6
|
+
|
|
7
|
+
async function count(label: string, body: unknown) {
|
|
8
|
+
const t0 = Date.now()
|
|
9
|
+
const res = await fetch(`${BASE}/companies/search/count`, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
|
|
12
|
+
body: JSON.stringify(body),
|
|
13
|
+
signal: AbortSignal.timeout(20_000),
|
|
14
|
+
})
|
|
15
|
+
const data: any = await res.json().catch(() => ({}))
|
|
16
|
+
const ms = Date.now() - t0
|
|
17
|
+
console.log(`[${label}]`)
|
|
18
|
+
console.log(` body=${JSON.stringify(body)}`)
|
|
19
|
+
console.log(` status=${res.status} ms=${ms} count=${data?.count} total=${data?.total}\n`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function main() {
|
|
23
|
+
// Baselines
|
|
24
|
+
await count('baseline empty', {})
|
|
25
|
+
await count('baseline countries=US', { countries: ['US'] })
|
|
26
|
+
|
|
27
|
+
// Range candidates — try every shape
|
|
28
|
+
await count('flat employeesFrom/employeesTo', { employeesFrom: 1000, employeesTo: 5000 })
|
|
29
|
+
await count('flat employees array [{from,to}]', { employees: [{ from: 1000, to: 5000 }] })
|
|
30
|
+
await count('flat employees object {from,to}', { employees: { from: 1000, to: 5000 } })
|
|
31
|
+
await count('flat employees scalar {min,max}', { employees: { min: 1000, max: 5000 } })
|
|
32
|
+
await count('flat employeesMin/employeesMax', { employeesMin: 1000, employeesMax: 5000 })
|
|
33
|
+
await count('flat headcount', { headcount: { from: 1000, to: 5000 } })
|
|
34
|
+
await count('flat employee_count', { employee_count: { from: 1000, to: 5000 } })
|
|
35
|
+
|
|
36
|
+
// Ranges combined with a primary filter (in case they need company narrowing first)
|
|
37
|
+
await count('countries=US + employeesFrom/To', { countries: ['US'], employeesFrom: 1000, employeesTo: 5000 })
|
|
38
|
+
await count('countries=US + employees array', { countries: ['US'], employees: [{ from: 1000, to: 5000 }] })
|
|
39
|
+
|
|
40
|
+
// Founded year shapes
|
|
41
|
+
await count('foundedFrom/foundedTo', { foundedFrom: 2015, foundedTo: 2020 })
|
|
42
|
+
await count('foundedYear {from,to}', { foundedYear: { from: 2015, to: 2020 } })
|
|
43
|
+
await count('foundedYearFrom/foundedYearTo', { foundedYearFrom: 2015, foundedYearTo: 2020 })
|
|
44
|
+
|
|
45
|
+
// Sanity — does a known-working filter still narrow alongside ranges?
|
|
46
|
+
await count('countries=BR (sanity)', { countries: ['BR'] })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
main()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Direct upstream probe — bypasses the marketplace proxy.
|
|
2
|
+
// Goal: figure out whether CompanyEnrich's /companies/search wants filters wrapped
|
|
3
|
+
// under { filters: {...} } (current behavior) or at the top level, and whether ANY
|
|
4
|
+
// filter shape narrows results away from their default top-5.
|
|
5
|
+
//
|
|
6
|
+
// Run: COMPANYENRICH_API_KEY=… npx tsx mcp/tests/live/companyenrich-upstream-probe.ts
|
|
7
|
+
|
|
8
|
+
const KEY = process.env.COMPANYENRICH_API_KEY
|
|
9
|
+
if (!KEY) {
|
|
10
|
+
console.error('COMPANYENRICH_API_KEY required')
|
|
11
|
+
process.exit(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const BASE = 'https://api.companyenrich.com'
|
|
15
|
+
|
|
16
|
+
async function call(body: unknown, label: string) {
|
|
17
|
+
const t0 = Date.now()
|
|
18
|
+
let res: Response
|
|
19
|
+
try {
|
|
20
|
+
res = await fetch(`${BASE}/companies/search`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
signal: AbortSignal.timeout(30_000),
|
|
25
|
+
})
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.log(`\n[${label}] body=${JSON.stringify(body)}`)
|
|
28
|
+
console.log(` fetch-failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
let data: any
|
|
32
|
+
try { data = await res.json() } catch { data = '<non-json>' }
|
|
33
|
+
const items = (data?.items ?? []).slice(0, 5).map((c: any) => `${c.name} (${c.domain})`)
|
|
34
|
+
console.log(`\n[${label}] body=${JSON.stringify(body)}`)
|
|
35
|
+
console.log(` status=${res.status} ms=${Date.now() - t0} count=${data?.items?.length ?? 0}`)
|
|
36
|
+
console.log(` top5=${JSON.stringify(items)}`)
|
|
37
|
+
if (!res.ok) console.log(` raw=${JSON.stringify(data).slice(0, 400)}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
console.log('=== CompanyEnrich /companies/search direct upstream probe ===')
|
|
42
|
+
console.log(`BASE=${BASE}`)
|
|
43
|
+
|
|
44
|
+
// A. What our marketplace currently sends
|
|
45
|
+
await call({ pageSize: 5, filters: { query: 'wework' } }, 'A. filters.query="wework" (our current shape)')
|
|
46
|
+
|
|
47
|
+
// B. Try query at top level
|
|
48
|
+
await call({ query: 'wework', pageSize: 5 }, 'B. top-level query="wework"')
|
|
49
|
+
|
|
50
|
+
// C. Try an exact identifier wrapped
|
|
51
|
+
await call({ pageSize: 5, filters: { domains: ['wework.com'] } }, 'C. filters.domains=["wework.com"]')
|
|
52
|
+
|
|
53
|
+
// D. Try an exact identifier top-level
|
|
54
|
+
await call({ domains: ['wework.com'], pageSize: 5 }, 'D. top-level domains=["wework.com"]')
|
|
55
|
+
|
|
56
|
+
// E. Try names wrapped
|
|
57
|
+
await call({ pageSize: 5, filters: { names: ['WeWork'] } }, 'E. filters.names=["WeWork"]')
|
|
58
|
+
|
|
59
|
+
// F. Country filter only (baseline narrowing)
|
|
60
|
+
await call({ pageSize: 5, filters: { countries: ['BR'] } }, 'F. filters.countries=["BR"] (baseline)')
|
|
61
|
+
|
|
62
|
+
// G. Country top-level
|
|
63
|
+
await call({ countries: ['BR'], pageSize: 5 }, 'G. top-level countries=["BR"]')
|
|
64
|
+
|
|
65
|
+
// H. Empty body — what does no filter return?
|
|
66
|
+
await call({ pageSize: 5 }, 'H. {pageSize:5} — empty body')
|
|
67
|
+
|
|
68
|
+
// I. semanticQuery (alternative full-text path)
|
|
69
|
+
await call({ pageSize: 5, filters: { semanticQuery: 'WeWork coworking real estate' } }, 'I. filters.semanticQuery="WeWork coworking real estate"')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
main().catch((err) => { console.error('FATAL:', err); process.exit(1) })
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Live prod trace for the "C-suite of WeWork Brazil" agent flow.
|
|
2
|
+
// Pinpoints which calls misbehave so we can fix find_people / find_emails / search_companies
|
|
3
|
+
// before re-running and measuring improvement.
|
|
4
|
+
//
|
|
5
|
+
// Run: COLDIQ_API_KEY=ciq_test_xxx tsx mcp/tests/live/wework-brazil-trace.ts
|
|
6
|
+
|
|
7
|
+
const API_URL = process.env.COLDIQ_API_URL ?? 'https://api.coldiq.com'
|
|
8
|
+
const API_KEY = process.env.COLDIQ_API_KEY
|
|
9
|
+
if (!API_KEY) {
|
|
10
|
+
console.error('COLDIQ_API_KEY required')
|
|
11
|
+
process.exit(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Result = { ok: boolean; status: number; data: unknown; ms: number }
|
|
15
|
+
|
|
16
|
+
async function call(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Result> {
|
|
17
|
+
const t0 = Date.now()
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(`${API_URL}/v1${path}`, {
|
|
20
|
+
method,
|
|
21
|
+
headers: {
|
|
22
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
23
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
24
|
+
},
|
|
25
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
26
|
+
signal: AbortSignal.timeout(120_000),
|
|
27
|
+
})
|
|
28
|
+
let data: unknown
|
|
29
|
+
try { data = await res.json() } catch { data = '<non-json>' }
|
|
30
|
+
return { ok: res.ok, status: res.status, data, ms: Date.now() - t0 }
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
status: 0,
|
|
35
|
+
data: { error: 'fetch-failed', message: err instanceof Error ? err.message : String(err) },
|
|
36
|
+
ms: Date.now() - t0,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function shortJson(v: unknown, max = 400): string {
|
|
42
|
+
const s = typeof v === 'string' ? v : JSON.stringify(v)
|
|
43
|
+
return s.length > max ? s.slice(0, max) + '…' : s
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function header(s: string) { console.log(`\n${'='.repeat(78)}\n${s}\n${'='.repeat(78)}`) }
|
|
47
|
+
function step(s: string) { console.log(`\n— ${s} —`) }
|
|
48
|
+
|
|
49
|
+
async function main() {
|
|
50
|
+
header('WeWork Brazil — live trace')
|
|
51
|
+
console.log(`API: ${API_URL}`)
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------
|
|
54
|
+
// 1. search_companies — does the corrected `query` field surface WeWork?
|
|
55
|
+
// Compares the buggy `keywords` shape (before-fix) with the fixed `query` shape (after-fix).
|
|
56
|
+
// ---------------------------------------------------------------
|
|
57
|
+
for (const kw of ['wework', 'wework brazil', 'WeWork']) {
|
|
58
|
+
step(`BEFORE-FIX companyenrich/companies/search keywords=[${kw}]`)
|
|
59
|
+
let r = await call('POST', '/companyenrich/companies/search', { keywords: [kw], pageSize: 5 })
|
|
60
|
+
console.log(` status=${r.status} ms=${r.ms} top=${(((r.data as any)?.items ?? [])[0]?.name) ?? '<none>'}`)
|
|
61
|
+
|
|
62
|
+
step(`AFTER-FIX companyenrich/companies/search query="${kw}"`)
|
|
63
|
+
r = await call('POST', '/companyenrich/companies/search', { query: kw, pageSize: 5 })
|
|
64
|
+
const top3 = (((r.data as any)?.items ?? []) as any[]).slice(0, 3).map((c: any) => c.name)
|
|
65
|
+
console.log(` status=${r.status} ms=${r.ms} top3=${JSON.stringify(top3)}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
step('fullenrich/company/search keywords=[wework]')
|
|
69
|
+
let r = await call('POST', '/fullenrich/company/search', {
|
|
70
|
+
keywords: [{ value: 'wework' }],
|
|
71
|
+
limit: 5,
|
|
72
|
+
})
|
|
73
|
+
console.log(` status=${r.status} ms=${r.ms} data=${shortJson(r.data, 400)}`)
|
|
74
|
+
|
|
75
|
+
step('signalbase/companies search=wework')
|
|
76
|
+
r = await call('GET', `/signalbase/companies?search=${encodeURIComponent('wework')}&limit=5`)
|
|
77
|
+
console.log(` status=${r.status} ms=${r.ms} data=${shortJson(r.data, 400)}`)
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------
|
|
80
|
+
// 2. enrich_company — confirm wework.com
|
|
81
|
+
// ---------------------------------------------------------------
|
|
82
|
+
step('enrich_company domain=wework.com')
|
|
83
|
+
r = await call('POST', '/companyenrich/companies/enrich', { domain: 'wework.com' })
|
|
84
|
+
console.log(` status=${r.status} ms=${r.ms}`)
|
|
85
|
+
console.log(` data=${shortJson(r.data, 400)}`)
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------
|
|
88
|
+
// 3. find_people — LeadsFactory WITHOUT country filter (current behavior)
|
|
89
|
+
// ---------------------------------------------------------------
|
|
90
|
+
step('LF contact-finder — wework.com, no country filter')
|
|
91
|
+
const lfNoCountry = await call('POST', '/leadsfactory/contact-finder/searches', {
|
|
92
|
+
company_domains: ['wework.com'],
|
|
93
|
+
search: {
|
|
94
|
+
max_persona_results: 10,
|
|
95
|
+
personas: [
|
|
96
|
+
{ job_title: 'CEO, Chief Executive Officer' },
|
|
97
|
+
{ job_title: 'Head of Sales, VP Sales' },
|
|
98
|
+
{ job_title: 'Country Manager, Managing Director' },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
console.log(` status=${lfNoCountry.status} ms=${lfNoCountry.ms}`)
|
|
103
|
+
console.log(` data=${shortJson(lfNoCountry.data, 400)}`)
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------
|
|
106
|
+
// 4. find_people — LeadsFactory WITH country filter (proposed fix)
|
|
107
|
+
// ---------------------------------------------------------------
|
|
108
|
+
step('LF contact-finder — wework.com + country_codes BR')
|
|
109
|
+
const lfBr = await call('POST', '/leadsfactory/contact-finder/searches', {
|
|
110
|
+
company_domains: ['wework.com'],
|
|
111
|
+
search: {
|
|
112
|
+
max_persona_results: 10,
|
|
113
|
+
personas: [
|
|
114
|
+
{ job_title: 'CEO, Chief Executive Officer' },
|
|
115
|
+
{ job_title: 'Head of Sales, VP Sales' },
|
|
116
|
+
{ job_title: 'Country Manager, Managing Director' },
|
|
117
|
+
],
|
|
118
|
+
country_codes: [{ code: 'BR', name: 'Brazil' }],
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
console.log(` status=${lfBr.status} ms=${lfBr.ms}`)
|
|
122
|
+
console.log(` data=${shortJson(lfBr.data, 600)}`)
|
|
123
|
+
|
|
124
|
+
// Poll one of them if async
|
|
125
|
+
function extractId(d: unknown): string | undefined {
|
|
126
|
+
const o = d as Record<string, unknown>
|
|
127
|
+
return (o?._id as string) ?? (o?.search_id as string)
|
|
128
|
+
}
|
|
129
|
+
const lfId = extractId(lfBr.data)
|
|
130
|
+
if (lfId) {
|
|
131
|
+
step(`Polling LF search ${lfId} (up to 90s)…`)
|
|
132
|
+
const deadline = Date.now() + 90_000
|
|
133
|
+
let attempt = 0
|
|
134
|
+
while (Date.now() < deadline) {
|
|
135
|
+
attempt++
|
|
136
|
+
const wait = attempt === 1 ? 2000 : attempt === 2 ? 5000 : 12000
|
|
137
|
+
await new Promise((r) => setTimeout(r, wait))
|
|
138
|
+
const pr = await call('GET', `/leadsfactory/contact-finder/searches/${lfId}`)
|
|
139
|
+
const d = pr.data as Record<string, unknown>
|
|
140
|
+
console.log(` attempt=${attempt} status=${d?.status} complete=${d?.nb_jobs_complete}/${d?.nb_jobs_total} (${pr.ms}ms)`)
|
|
141
|
+
if (d?.status === 'SUCCESSFUL' || d?.status === 'FAILED' || d?.status === 'INVALID_URL') {
|
|
142
|
+
console.log(` final=${shortJson(d, 1200)}`)
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------
|
|
149
|
+
// 5. find_emails (bulk) vs find_email (single) on the same people
|
|
150
|
+
// Use realistic test people from a public source — Estefania Barbosa @ wework.com
|
|
151
|
+
// ---------------------------------------------------------------
|
|
152
|
+
const testPeople = [
|
|
153
|
+
{ id: 'estefania', first_name: 'Estefania', last_name: 'Barbosa', domain: 'wework.com' },
|
|
154
|
+
{ id: 'marcelo', first_name: 'Marcelo', last_name: 'Russo', domain: 'wework.com' },
|
|
155
|
+
{ id: 'marina', first_name: 'Marina', last_name: 'Soares', domain: 'wework.com' },
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
step('Prospeo bulk (the bulk-find_emails Step 1)')
|
|
159
|
+
const prospeoBulk = await call('POST', '/prospeo/bulk-enrich-person', {
|
|
160
|
+
data: testPeople.map((p) => ({
|
|
161
|
+
identifier: p.id, first_name: p.first_name, last_name: p.last_name, company_name: p.domain,
|
|
162
|
+
})),
|
|
163
|
+
})
|
|
164
|
+
console.log(` status=${prospeoBulk.status} ms=${prospeoBulk.ms}`)
|
|
165
|
+
console.log(` data=${shortJson(prospeoBulk.data, 800)}`)
|
|
166
|
+
|
|
167
|
+
step('Findymail single — each person, in parallel')
|
|
168
|
+
const findyResults = await Promise.all(
|
|
169
|
+
testPeople.map(async (p) => {
|
|
170
|
+
const fr = await call('POST', '/findymail/search/name', {
|
|
171
|
+
name: `${p.first_name} ${p.last_name}`,
|
|
172
|
+
domain: p.domain,
|
|
173
|
+
})
|
|
174
|
+
return { id: p.id, status: fr.status, ms: fr.ms, data: fr.data }
|
|
175
|
+
}),
|
|
176
|
+
)
|
|
177
|
+
for (const f of findyResults) {
|
|
178
|
+
console.log(` ${f.id}: status=${f.status} ms=${f.ms} → ${shortJson(f.data, 200)}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
step('Icypeas single — each person, in parallel')
|
|
182
|
+
const icyResults = await Promise.all(
|
|
183
|
+
testPeople.map(async (p) => {
|
|
184
|
+
const ir = await call('POST', '/icypeas/email-search', {
|
|
185
|
+
firstname: p.first_name, lastname: p.last_name, domainOrCompany: p.domain,
|
|
186
|
+
})
|
|
187
|
+
return { id: p.id, status: ir.status, ms: ir.ms, data: ir.data }
|
|
188
|
+
}),
|
|
189
|
+
)
|
|
190
|
+
for (const f of icyResults) {
|
|
191
|
+
console.log(` ${f.id}: status=${f.status} ms=${f.ms} → ${shortJson(f.data, 200)}`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
header('Done')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
main().catch((err) => {
|
|
198
|
+
console.error('FATAL:', err)
|
|
199
|
+
process.exit(1)
|
|
200
|
+
})
|
|
@@ -430,6 +430,77 @@ describe('leadsfactory (find_people)', () => {
|
|
|
430
430
|
})
|
|
431
431
|
})
|
|
432
432
|
|
|
433
|
+
it('locations are forwarded as ISO country_codes pairs', () => {
|
|
434
|
+
const result = p().mapParams({
|
|
435
|
+
company_domains: ['wework.com'],
|
|
436
|
+
job_titles: ['CEO'],
|
|
437
|
+
locations: ['BR', 'US'],
|
|
438
|
+
limit: 10,
|
|
439
|
+
})
|
|
440
|
+
const search = (result.body as Record<string, unknown>).search as Record<string, unknown>
|
|
441
|
+
expect(search.country_codes).toEqual([
|
|
442
|
+
{ code: 'BR', name: 'Brazil' },
|
|
443
|
+
{ code: 'US', name: 'United States' },
|
|
444
|
+
])
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('country names are coerced to ISO-2 (agents commonly pass "Brazil" instead of "BR")', () => {
|
|
448
|
+
const result = p().mapParams({
|
|
449
|
+
company_domains: ['wework.com'],
|
|
450
|
+
job_titles: ['CEO'],
|
|
451
|
+
locations: ['Brazil', 'United States'],
|
|
452
|
+
limit: 10,
|
|
453
|
+
})
|
|
454
|
+
const search = (result.body as Record<string, unknown>).search as Record<string, unknown>
|
|
455
|
+
expect(search.country_codes).toEqual([
|
|
456
|
+
{ code: 'BR', name: 'Brazil' },
|
|
457
|
+
{ code: 'US', name: 'United States' },
|
|
458
|
+
])
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('common aliases (USA, UK) and case-insensitive names are coerced', () => {
|
|
462
|
+
const result = p().mapParams({
|
|
463
|
+
company_domains: ['x.com'],
|
|
464
|
+
job_titles: ['CEO'],
|
|
465
|
+
locations: ['usa', 'UK', 'south korea'],
|
|
466
|
+
limit: 5,
|
|
467
|
+
})
|
|
468
|
+
const search = (result.body as Record<string, unknown>).search as Record<string, unknown>
|
|
469
|
+
expect(search.country_codes).toEqual([
|
|
470
|
+
{ code: 'US', name: 'United States' },
|
|
471
|
+
{ code: 'GB', name: 'United Kingdom' },
|
|
472
|
+
{ code: 'KR', name: 'South Korea' },
|
|
473
|
+
])
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('unrecognized location strings are dropped rather than forwarded as garbage', () => {
|
|
477
|
+
const result = p().mapParams({
|
|
478
|
+
company_domains: ['x.com'],
|
|
479
|
+
job_titles: ['CEO'],
|
|
480
|
+
locations: ['Atlantis', 'BR'],
|
|
481
|
+
limit: 5,
|
|
482
|
+
})
|
|
483
|
+
const search = (result.body as Record<string, unknown>).search as Record<string, unknown>
|
|
484
|
+
expect(search.country_codes).toEqual([{ code: 'BR', name: 'Brazil' }])
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('country_codes is omitted when locations is not provided', () => {
|
|
488
|
+
const result = p().mapParams({ company_domains: ['coldiq.com'], job_titles: ['CEO'], limit: 5 })
|
|
489
|
+
const search = (result.body as Record<string, unknown>).search as Record<string, unknown>
|
|
490
|
+
expect(search.country_codes).toBeUndefined()
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('country_codes is omitted when locations is provided but no value coerces (all unrecognized)', () => {
|
|
494
|
+
const result = p().mapParams({
|
|
495
|
+
company_domains: ['x.com'],
|
|
496
|
+
job_titles: ['CEO'],
|
|
497
|
+
locations: ['Atlantis', 'Narnia'],
|
|
498
|
+
limit: 5,
|
|
499
|
+
})
|
|
500
|
+
const search = (result.body as Record<string, unknown>).search as Record<string, unknown>
|
|
501
|
+
expect(search.country_codes).toBeUndefined()
|
|
502
|
+
})
|
|
503
|
+
|
|
433
504
|
it('seniorities and keywords are not forwarded', () => {
|
|
434
505
|
const result = p().mapParams({
|
|
435
506
|
company_domains: ['coldiq.com'],
|
|
@@ -442,6 +513,46 @@ describe('leadsfactory (find_people)', () => {
|
|
|
442
513
|
expect(body.seniorities).toBeUndefined()
|
|
443
514
|
expect(body.keywords).toBeUndefined()
|
|
444
515
|
})
|
|
516
|
+
|
|
517
|
+
it('hasResult: true when companies_personas is non-empty', () => {
|
|
518
|
+
expect(p().hasResult({ companies_personas: [{ company: 'ColdIQ' }] })).toBe(true)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('hasResult: false when companies_personas is empty array', () => {
|
|
522
|
+
expect(p().hasResult({ companies_personas: [] })).toBe(false)
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('hasResult: true when SUCCESSFUL + nb_jobs_complete > 0 (no companies_personas in response)', () => {
|
|
526
|
+
expect(p().hasResult({ status: 'SUCCESSFUL', nb_jobs_complete: 3 })).toBe(true)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('hasResult: false when SUCCESSFUL + nb_jobs_complete is null (no results)', () => {
|
|
530
|
+
expect(p().hasResult({ status: 'SUCCESSFUL', nb_jobs_complete: null })).toBe(false)
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('hasResult: false when SUCCESSFUL + nb_jobs_complete is 0', () => {
|
|
534
|
+
expect(p().hasResult({ status: 'SUCCESSFUL', nb_jobs_complete: 0 })).toBe(false)
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('isComplete: true for SUCCESSFUL', () => {
|
|
538
|
+
expect(p().async!.isComplete({ status: 'SUCCESSFUL' })).toBe(true)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('isComplete: true for FAILED', () => {
|
|
542
|
+
expect(p().async!.isComplete({ status: 'FAILED' })).toBe(true)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('isComplete: true for INVALID_URL (terminal error state)', () => {
|
|
546
|
+
expect(p().async!.isComplete({ status: 'INVALID_URL' })).toBe(true)
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('isComplete: false for RUNNING', () => {
|
|
550
|
+
expect(p().async!.isComplete({ status: 'RUNNING' })).toBe(false)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('isComplete: false for PAUSED', () => {
|
|
554
|
+
expect(p().async!.isComplete({ status: 'PAUSED' })).toBe(false)
|
|
555
|
+
})
|
|
445
556
|
})
|
|
446
557
|
|
|
447
558
|
// ---------------------------------------------------------------------------
|
package/tests/registry.test.ts
CHANGED
|
@@ -48,7 +48,7 @@ describe('registry', () => {
|
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
describe('search_companies mapParams', () => {
|
|
51
|
-
it('CompanyEnrich
|
|
51
|
+
it('CompanyEnrich routes keywords + industries into the `query` full-text field (not the tag-filter `keywords` field)', () => {
|
|
52
52
|
const providers = getProviders('search_companies')
|
|
53
53
|
const ce = providers.find((p) => p.id === 'companyenrich')!
|
|
54
54
|
const result = ce.mapParams({
|
|
@@ -59,13 +59,30 @@ describe('registry', () => {
|
|
|
59
59
|
max_employees: 200,
|
|
60
60
|
limit: 25,
|
|
61
61
|
})
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
const body = result.body as Record<string, unknown>
|
|
63
|
+
expect(body.query).toBe('SaaS Software')
|
|
64
|
+
expect(body.keywords).toBeUndefined()
|
|
65
|
+
expect(body.countries).toEqual(['US'])
|
|
66
|
+
expect(body.employees).toEqual([{ from: 10, to: 200 }])
|
|
67
|
+
expect(body.pageSize).toBe(25)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('CompanyEnrich brand-name keyword goes to `query` (the WeWork regression)', () => {
|
|
71
|
+
const providers = getProviders('search_companies')
|
|
72
|
+
const ce = providers.find((p) => p.id === 'companyenrich')!
|
|
73
|
+
const result = ce.mapParams({ keywords: ['wework'], limit: 5 })
|
|
74
|
+
const body = result.body as Record<string, unknown>
|
|
75
|
+
expect(body.query).toBe('wework')
|
|
76
|
+
expect(body.keywords).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('CompanyEnrich omits `query` when no keywords/industries provided', () => {
|
|
80
|
+
const providers = getProviders('search_companies')
|
|
81
|
+
const ce = providers.find((p) => p.id === 'companyenrich')!
|
|
82
|
+
const result = ce.mapParams({ countries: ['US'], limit: 5 })
|
|
83
|
+
const body = result.body as Record<string, unknown>
|
|
84
|
+
expect(body.query).toBeUndefined()
|
|
85
|
+
expect(body.keywords).toBeUndefined()
|
|
69
86
|
})
|
|
70
87
|
|
|
71
88
|
it('Apollo maps correctly, translating ISO country codes to English names', () => {
|
|
@@ -1255,7 +1272,7 @@ describe('registry', () => {
|
|
|
1255
1272
|
})
|
|
1256
1273
|
expect(result.body).toEqual({
|
|
1257
1274
|
countries: ['US'],
|
|
1258
|
-
|
|
1275
|
+
query: 'SaaS',
|
|
1259
1276
|
technologies: ['Salesforce', 'HubSpot'],
|
|
1260
1277
|
employees: undefined,
|
|
1261
1278
|
foundedYear: undefined,
|
|
@@ -454,4 +454,57 @@ describe('find_emails handler — resilience', () => {
|
|
|
454
454
|
expect(parsed.data.found).toBe(1)
|
|
455
455
|
expect(parsed.data.results[0]).toMatchObject({ id: 'p1', email: 'alice@example.com', provider: 'findymail' })
|
|
456
456
|
})
|
|
457
|
+
|
|
458
|
+
it('falls back to single find_email waterfall when bulk providers all miss (covers limadata/blitzapi/linkupapi gap)', async () => {
|
|
459
|
+
// Bulk path: Prospeo + FullEnrich + Findymail + Icypeas all miss.
|
|
460
|
+
// Single-only providers (limadata-work-email, blitzapi, limadata-work-email-linkedin, linkupapi)
|
|
461
|
+
// should be tried; blitzapi finds the email.
|
|
462
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
463
|
+
const u = url.toString()
|
|
464
|
+
if (u.includes('/prospeo/bulk-enrich-person')) {
|
|
465
|
+
return new Response(
|
|
466
|
+
JSON.stringify({ error: false, results: [{ identifier: 'p1', error: true, person: null }], total_cost: 0 }),
|
|
467
|
+
{ status: 200 },
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
if (u.includes('/fullenrich/contact/enrich/bulk')) {
|
|
471
|
+
// No enrichment_id returned → fullEnrichStep exits early without polling
|
|
472
|
+
return new Response(JSON.stringify({}), { status: 200 })
|
|
473
|
+
}
|
|
474
|
+
if (u.includes('/findymail/search/name')) {
|
|
475
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
476
|
+
}
|
|
477
|
+
if (u.includes('/icypeas/email-search')) {
|
|
478
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
479
|
+
}
|
|
480
|
+
// Single-only providers — limadata first (per priority), then blitzapi.
|
|
481
|
+
if (u.includes('/coldiq/find/work-email-linkedin')) {
|
|
482
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
483
|
+
}
|
|
484
|
+
if (u.includes('/coldiq/find/work-email')) {
|
|
485
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
486
|
+
}
|
|
487
|
+
if (u.includes('/blitzapi/enrichment/find-work-email')) {
|
|
488
|
+
return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
|
|
489
|
+
}
|
|
490
|
+
if (u.includes('/linkupapi/data/mail/finder')) {
|
|
491
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
492
|
+
}
|
|
493
|
+
return new Response(JSON.stringify({ error: 'unexpected', url: u }), { status: 500 })
|
|
494
|
+
}) as typeof fetch
|
|
495
|
+
|
|
496
|
+
const result = await findEmailsHandler({
|
|
497
|
+
people: [{
|
|
498
|
+
id: 'p1',
|
|
499
|
+
first_name: 'Alice',
|
|
500
|
+
last_name: 'Smith',
|
|
501
|
+
domain: 'example.com',
|
|
502
|
+
linkedin_url: 'https://linkedin.com/in/alice',
|
|
503
|
+
}],
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
507
|
+
expect(parsed.data.found).toBe(1)
|
|
508
|
+
expect(parsed.data.results[0]).toMatchObject({ id: 'p1', email: 'alice@example.com', provider: 'blitzapi' })
|
|
509
|
+
})
|
|
457
510
|
})
|