fullstackgtm 0.34.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +141 -0
- package/INSTALL_FOR_AGENTS.md +8 -0
- package/README.md +39 -0
- package/dist/acquireLinkedIn.d.ts +41 -0
- package/dist/acquireLinkedIn.js +57 -0
- package/dist/acquireMeter.d.ts +67 -0
- package/dist/acquireMeter.js +145 -0
- package/dist/acquireSeen.d.ts +5 -0
- package/dist/acquireSeen.js +54 -0
- package/dist/assign.d.ts +83 -0
- package/dist/assign.js +146 -0
- package/dist/bin.js +14 -2
- package/dist/cli.js +817 -26
- package/dist/connectors/hubspot.js +140 -0
- package/dist/connectors/linkedin.d.ts +78 -0
- package/dist/connectors/linkedin.js +199 -0
- package/dist/connectors/prospectSources.d.ts +91 -0
- package/dist/connectors/prospectSources.js +227 -0
- package/dist/enrich.d.ts +107 -0
- package/dist/enrich.js +315 -5
- package/dist/format.d.ts +3 -1
- package/dist/format.js +14 -2
- package/dist/health.d.ts +71 -0
- package/dist/health.js +172 -0
- package/dist/icp.d.ts +96 -0
- package/dist/icp.js +256 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mappings.js +3 -0
- package/dist/reassign.d.ts +11 -2
- package/dist/reassign.js +13 -6
- package/dist/runReport.d.ts +9 -0
- package/dist/runReport.js +66 -0
- package/dist/types.d.ts +25 -1
- package/docs/api.md +53 -3
- package/docs/architecture.md +11 -1
- package/docs/dx-punch-list.md +87 -0
- package/docs/linkedin-connector-spec.md +87 -0
- package/llms.txt +38 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +5 -3
- package/src/acquireLinkedIn.ts +83 -0
- package/src/acquireMeter.ts +186 -0
- package/src/acquireSeen.ts +57 -0
- package/src/assign.ts +193 -0
- package/src/bin.ts +17 -4
- package/src/cli.ts +965 -25
- package/src/connectors/hubspot.ts +145 -0
- package/src/connectors/linkedin.ts +272 -0
- package/src/connectors/prospectSources.ts +324 -0
- package/src/enrich.ts +411 -5
- package/src/format.ts +20 -2
- package/src/health.ts +238 -0
- package/src/icp.ts +313 -0
- package/src/index.ts +18 -0
- package/src/mappings.ts +3 -0
- package/src/reassign.ts +24 -8
- package/src/runReport.ts +76 -0
- package/src/types.ts +32 -0
|
@@ -26,6 +26,10 @@ export function createHubspotConnector(options) {
|
|
|
26
26
|
// search API is eventually consistent, so a just-created company is
|
|
27
27
|
// invisible to search — this map is the authoritative same-run record.
|
|
28
28
|
const createdCompaniesByName = new Map();
|
|
29
|
+
// Same-run dedup for `create_record` contact creates, keyed by lowercased
|
|
30
|
+
// match value (email): HubSpot search is eventually consistent, so a contact
|
|
31
|
+
// created earlier in this apply run is invisible to a later search.
|
|
32
|
+
const createdContactsByMatch = new Map();
|
|
29
33
|
async function request(path, init = {}) {
|
|
30
34
|
const token = await options.getAccessToken();
|
|
31
35
|
let response;
|
|
@@ -161,6 +165,7 @@ export function createHubspotConnector(options) {
|
|
|
161
165
|
email: stringOrUndefined(readMapped(props, "contacts", "email", "email")),
|
|
162
166
|
phone: stringOrUndefined(readMapped(props, "contacts", "phone", "phone")),
|
|
163
167
|
title: stringOrUndefined(readMapped(props, "contacts", "title", "jobtitle")),
|
|
168
|
+
linkedin: stringOrUndefined(readMapped(props, "contacts", "linkedin", "hs_linkedin_url")),
|
|
164
169
|
ownerId: stringOrUndefined(readMapped(props, "contacts", "ownerId", "hubspot_owner_id")),
|
|
165
170
|
provenance: provenanceFrom(props),
|
|
166
171
|
lastSyncAt: stringOrUndefined(contact.updatedAt),
|
|
@@ -413,6 +418,139 @@ export function createHubspotConnector(options) {
|
|
|
413
418
|
});
|
|
414
419
|
return (data?.results ?? []).map((row) => String(row.id));
|
|
415
420
|
}
|
|
421
|
+
/** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
|
|
422
|
+
async function searchContactsBy(property, value) {
|
|
423
|
+
const data = await request(`/crm/v3/objects/contacts/search`, {
|
|
424
|
+
method: "POST",
|
|
425
|
+
body: JSON.stringify({
|
|
426
|
+
filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
|
|
427
|
+
properties: [property],
|
|
428
|
+
limit: 3,
|
|
429
|
+
}),
|
|
430
|
+
});
|
|
431
|
+
return (data?.results ?? []).map((row) => String(row.id));
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Resolve a company by exact name, creating it on a confirmed miss. Returns
|
|
435
|
+
* the company id, or null on ambiguity (≥2 existing) so the caller skips the
|
|
436
|
+
* association rather than guessing. Same-run safe via createdCompaniesByName.
|
|
437
|
+
*/
|
|
438
|
+
async function resolveOrCreateCompanyByName(name, opId) {
|
|
439
|
+
const nameKey = name.toLowerCase();
|
|
440
|
+
const already = createdCompaniesByName.get(nameKey);
|
|
441
|
+
if (already)
|
|
442
|
+
return already;
|
|
443
|
+
const matches = await searchCompaniesByName(name);
|
|
444
|
+
if (matches.length > 1)
|
|
445
|
+
return null;
|
|
446
|
+
if (matches.length === 1) {
|
|
447
|
+
createdCompaniesByName.set(nameKey, matches[0]);
|
|
448
|
+
return matches[0];
|
|
449
|
+
}
|
|
450
|
+
let created;
|
|
451
|
+
try {
|
|
452
|
+
created = await request(`/crm/v3/objects/companies`, {
|
|
453
|
+
method: "POST",
|
|
454
|
+
body: JSON.stringify({
|
|
455
|
+
properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
|
|
456
|
+
}),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
created = await request(`/crm/v3/objects/companies`, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
body: JSON.stringify({ properties: { name } }),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
const id = String(created.id);
|
|
466
|
+
createdCompaniesByName.set(nameKey, id);
|
|
467
|
+
return id;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Create a NET-NEW record (a sourced lead). Resolve-first: re-checks the
|
|
471
|
+
* dedupe key against the live CRM (the plan-time snapshot can be stale) and
|
|
472
|
+
* creates ONLY on a confirmed miss — a record a concurrent writer already
|
|
473
|
+
* added is returned as `skipped`, never duplicated. Contacts can be linked
|
|
474
|
+
* to a resolved-or-created company in the same step.
|
|
475
|
+
*/
|
|
476
|
+
async function createRecord(operation) {
|
|
477
|
+
const payload = operation.afterValue;
|
|
478
|
+
if (!payload || typeof payload !== "object" || !payload.properties) {
|
|
479
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
|
|
480
|
+
}
|
|
481
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
482
|
+
if (operation.objectType !== "contact" && operation.objectType !== "account") {
|
|
483
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
|
|
484
|
+
}
|
|
485
|
+
const matchValue = String(payload.matchValue ?? "").trim();
|
|
486
|
+
if (!matchValue) {
|
|
487
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
|
|
488
|
+
}
|
|
489
|
+
if (operation.objectType === "account") {
|
|
490
|
+
const id = await resolveOrCreateCompanyByName(matchValue, operation.id);
|
|
491
|
+
if (id === null) {
|
|
492
|
+
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
|
|
493
|
+
}
|
|
494
|
+
return { operationId: operation.id, status: "applied", detail: `Resolved/created company "${matchValue}" (${id}).`, providerData: { id } };
|
|
495
|
+
}
|
|
496
|
+
// contact — resolve-first on the dedupe key (email by default)
|
|
497
|
+
const matchKey = payload.matchKey || "email";
|
|
498
|
+
const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
|
|
499
|
+
if (createdContactsByMatch.has(matchKeyLower)) {
|
|
500
|
+
return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
|
|
501
|
+
}
|
|
502
|
+
const existing = await searchContactsBy(matchKey, matchValue);
|
|
503
|
+
if (existing.length > 0) {
|
|
504
|
+
createdContactsByMatch.set(matchKeyLower, existing[0]);
|
|
505
|
+
return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.join(", ")}); resolve-first declined to create.`, providerData: { id: existing[0], existing: true } };
|
|
506
|
+
}
|
|
507
|
+
const properties = { ...payload.properties };
|
|
508
|
+
// Stamp ownership at create time so the lead is never born ownerless. The
|
|
509
|
+
// canonical ownerId maps to HubSpot's standard owner property.
|
|
510
|
+
if (payload.ownerId && !properties.hubspot_owner_id) {
|
|
511
|
+
properties.hubspot_owner_id = String(payload.ownerId);
|
|
512
|
+
}
|
|
513
|
+
let created;
|
|
514
|
+
try {
|
|
515
|
+
created = await request(`/crm/v3/objects/${objectPath}`, {
|
|
516
|
+
method: "POST",
|
|
517
|
+
body: JSON.stringify({ properties: { ...properties, hs_object_source_detail_2: `fullstackgtm acquire (${operation.id})` } }),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
// Some portals reject writes to source-detail properties — the provenance
|
|
522
|
+
// stamp is best-effort, the create is not.
|
|
523
|
+
created = await request(`/crm/v3/objects/${objectPath}`, {
|
|
524
|
+
method: "POST",
|
|
525
|
+
body: JSON.stringify({ properties }),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
const contactId = String(created.id);
|
|
529
|
+
createdContactsByMatch.set(matchKeyLower, contactId);
|
|
530
|
+
let companyNote = "";
|
|
531
|
+
if (payload.associateCompanyName) {
|
|
532
|
+
try {
|
|
533
|
+
const companyId = await resolveOrCreateCompanyByName(payload.associateCompanyName, operation.id);
|
|
534
|
+
if (companyId) {
|
|
535
|
+
await request(`/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
|
|
536
|
+
companyNote = ` Linked to company "${payload.associateCompanyName}" (${companyId}).`;
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
// Association is best-effort — the contact create already succeeded.
|
|
544
|
+
companyNote = ` (company link failed: ${error instanceof Error ? error.message : String(error)})`;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
operationId: operation.id,
|
|
549
|
+
status: "applied",
|
|
550
|
+
detail: `Created contact ${matchKey}=${matchValue} (${contactId}).${companyNote}`,
|
|
551
|
+
providerData: { id: contactId, created: true },
|
|
552
|
+
};
|
|
553
|
+
}
|
|
416
554
|
async function createTask(operation) {
|
|
417
555
|
const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
|
|
418
556
|
if (associationTypeId === undefined) {
|
|
@@ -602,6 +740,8 @@ export function createHubspotConnector(options) {
|
|
|
602
740
|
return await linkRecord(operation);
|
|
603
741
|
case "create_task":
|
|
604
742
|
return await createTask(operation);
|
|
743
|
+
case "create_record":
|
|
744
|
+
return await createRecord(operation);
|
|
605
745
|
case "merge_records":
|
|
606
746
|
return await mergeRecords(operation);
|
|
607
747
|
case "archive_record":
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn connector — Phase 1 discovery (read-only).
|
|
3
|
+
*
|
|
4
|
+
* A provider-agnostic surface for pulling prospects out of LinkedIn via a
|
|
5
|
+
* rented execution layer (default: HeyReach; Unipile is a drop-in alternative).
|
|
6
|
+
* Deliberately decoupled from the acquire spine — the `Icp`→filter and
|
|
7
|
+
* `LinkedInProspect`→`Prospect` bridges live in the `enrich acquire --source
|
|
8
|
+
* linkedin` wiring, not here — so this layer can land and be tested on its own.
|
|
9
|
+
*
|
|
10
|
+
* Phase 2 (future) extends `LinkedInProvider` with send actions
|
|
11
|
+
* (connect/message) routed through the plan/apply gate. Spec:
|
|
12
|
+
* docs/linkedin-connector-spec.md. Nothing here ever writes or sends.
|
|
13
|
+
*/
|
|
14
|
+
export type LinkedInProspect = {
|
|
15
|
+
firstName: string;
|
|
16
|
+
lastName: string;
|
|
17
|
+
fullName?: string;
|
|
18
|
+
headline?: string;
|
|
19
|
+
jobTitle?: string;
|
|
20
|
+
company?: string;
|
|
21
|
+
/** Canonical LinkedIn profile URL — the identity key dedup uses downstream. */
|
|
22
|
+
profileUrl: string;
|
|
23
|
+
location?: string;
|
|
24
|
+
email?: string;
|
|
25
|
+
emailStatus?: string;
|
|
26
|
+
/** The raw provider payload, preserved for debugging and later mapping. */
|
|
27
|
+
raw?: unknown;
|
|
28
|
+
};
|
|
29
|
+
export type LinkedInSource = {
|
|
30
|
+
/** Provider-native id of a prospect source (HeyReach: a lead-list id). */
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
/** Approximate prospect count, when the provider reports it. */
|
|
34
|
+
count?: number;
|
|
35
|
+
};
|
|
36
|
+
export type FetchProspectsOptions = {
|
|
37
|
+
/** Which source to read (HeyReach lead-list id); provider may have a default. */
|
|
38
|
+
sourceId?: string;
|
|
39
|
+
/** Hard cap on prospects returned; also bounds pagination. */
|
|
40
|
+
max?: number;
|
|
41
|
+
};
|
|
42
|
+
export type ConnectionStatus = {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
detail: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Provider-agnostic LinkedIn discovery surface (Phase 1). Read-only: it
|
|
48
|
+
* enumerates prospect sources and pulls normalized prospects, never sends.
|
|
49
|
+
*/
|
|
50
|
+
export interface LinkedInProvider {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
/** Cheap auth/connectivity check; never throws — returns ok:false instead. */
|
|
53
|
+
checkConnection(): Promise<ConnectionStatus>;
|
|
54
|
+
/** Enumerate prospect sources (HeyReach lead lists). */
|
|
55
|
+
listSources(): Promise<LinkedInSource[]>;
|
|
56
|
+
/** Pull prospects from a source, normalized + capped. */
|
|
57
|
+
fetchProspects(options?: FetchProspectsOptions): Promise<LinkedInProspect[]>;
|
|
58
|
+
}
|
|
59
|
+
export declare const HEYREACH_BASE = "https://api.heyreach.io/api/public";
|
|
60
|
+
export type HeyReachProviderOptions = {
|
|
61
|
+
apiKey: string;
|
|
62
|
+
fetchImpl?: typeof fetch;
|
|
63
|
+
baseUrl?: string;
|
|
64
|
+
/** Lead-list id used when fetchProspects is called without an explicit sourceId. */
|
|
65
|
+
defaultListId?: string;
|
|
66
|
+
/** Page size for paginated reads (≤ server max). Lower in tests. */
|
|
67
|
+
pageSize?: number;
|
|
68
|
+
};
|
|
69
|
+
export declare function createHeyReachProvider(options: HeyReachProviderOptions): LinkedInProvider;
|
|
70
|
+
/** Map a HeyReach lead (nested `profile` or flat) into the normalized shape. */
|
|
71
|
+
export declare function normalizeHeyReachLead(raw: any): LinkedInProspect;
|
|
72
|
+
export declare const FAKE_LINKEDIN_PROSPECTS: LinkedInProspect[];
|
|
73
|
+
export type FakeLinkedInOptions = {
|
|
74
|
+
prospects?: LinkedInProspect[];
|
|
75
|
+
sources?: LinkedInSource[];
|
|
76
|
+
connection?: ConnectionStatus;
|
|
77
|
+
};
|
|
78
|
+
export declare function createFakeLinkedInProvider(opts?: FakeLinkedInOptions): LinkedInProvider;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn connector — Phase 1 discovery (read-only).
|
|
3
|
+
*
|
|
4
|
+
* A provider-agnostic surface for pulling prospects out of LinkedIn via a
|
|
5
|
+
* rented execution layer (default: HeyReach; Unipile is a drop-in alternative).
|
|
6
|
+
* Deliberately decoupled from the acquire spine — the `Icp`→filter and
|
|
7
|
+
* `LinkedInProspect`→`Prospect` bridges live in the `enrich acquire --source
|
|
8
|
+
* linkedin` wiring, not here — so this layer can land and be tested on its own.
|
|
9
|
+
*
|
|
10
|
+
* Phase 2 (future) extends `LinkedInProvider` with send actions
|
|
11
|
+
* (connect/message) routed through the plan/apply gate. Spec:
|
|
12
|
+
* docs/linkedin-connector-spec.md. Nothing here ever writes or sends.
|
|
13
|
+
*/
|
|
14
|
+
// ── HeyReach adapter ────────────────────────────────────────────────────────
|
|
15
|
+
// Base + auth verified 2026-06-20: POST endpoints, `X-API-KEY` header, 300
|
|
16
|
+
// req/min (429). Confirmed live: /auth/CheckApiKey, /list/GetAll. The
|
|
17
|
+
// leads-from-list path has a documented gap in HeyReach's public docs and is
|
|
18
|
+
// marked to reconcile at live validation (step 3, needs a real key).
|
|
19
|
+
export const HEYREACH_BASE = "https://api.heyreach.io/api/public";
|
|
20
|
+
const HEYREACH_MAX_PAGE = 100;
|
|
21
|
+
export function createHeyReachProvider(options) {
|
|
22
|
+
const apiKey = options.apiKey;
|
|
23
|
+
if (!apiKey) {
|
|
24
|
+
throw new Error("HeyReach API key is required (`login heyreach` or HEYREACH_API_KEY).");
|
|
25
|
+
}
|
|
26
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
27
|
+
const baseUrl = (options.baseUrl ?? HEYREACH_BASE).replace(/\/$/, "");
|
|
28
|
+
const pageSize = Math.max(1, Math.min(options.pageSize ?? HEYREACH_MAX_PAGE, HEYREACH_MAX_PAGE));
|
|
29
|
+
async function call(path, method, body) {
|
|
30
|
+
let response;
|
|
31
|
+
try {
|
|
32
|
+
response = await fetchImpl(`${baseUrl}${path}`, {
|
|
33
|
+
method,
|
|
34
|
+
// Secret travels in the header, never the URL (no leak via logs/history).
|
|
35
|
+
headers: {
|
|
36
|
+
"X-API-KEY": apiKey,
|
|
37
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
38
|
+
},
|
|
39
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error(`Cannot reach HeyReach at ${baseUrl}${path}. Check network access.`);
|
|
44
|
+
}
|
|
45
|
+
if (response.status === 429) {
|
|
46
|
+
const retry = response.headers.get("Retry-After");
|
|
47
|
+
throw new Error(`HeyReach rate limit (429)${retry ? `; retry after ${retry}s` : ""}. The cap is 300 requests/min.`);
|
|
48
|
+
}
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
await response.text().catch(() => undefined);
|
|
51
|
+
throw new Error(`HeyReach API error ${response.status} on ${path}. Check the API key and request.`);
|
|
52
|
+
}
|
|
53
|
+
if (response.status === 204)
|
|
54
|
+
return undefined;
|
|
55
|
+
return response.json().catch(() => undefined);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
name: "heyreach",
|
|
59
|
+
async checkConnection() {
|
|
60
|
+
try {
|
|
61
|
+
await call("/auth/CheckApiKey", "GET");
|
|
62
|
+
return { ok: true, detail: "HeyReach API key valid." };
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async listSources() {
|
|
69
|
+
const data = await call("/list/GetAll", "POST", { offset: 0, limit: HEYREACH_MAX_PAGE });
|
|
70
|
+
return toArray(data).map((item) => ({
|
|
71
|
+
id: String(item?.id ?? item?.listId ?? ""),
|
|
72
|
+
name: String(item?.name ?? item?.title ?? "(unnamed list)"),
|
|
73
|
+
count: typeof item?.totalItemsCount === "number"
|
|
74
|
+
? item.totalItemsCount
|
|
75
|
+
: typeof item?.count === "number"
|
|
76
|
+
? item.count
|
|
77
|
+
: undefined,
|
|
78
|
+
}));
|
|
79
|
+
},
|
|
80
|
+
async fetchProspects(opts = {}) {
|
|
81
|
+
const listId = opts.sourceId ?? options.defaultListId;
|
|
82
|
+
if (!listId) {
|
|
83
|
+
throw new Error("HeyReach fetchProspects needs a sourceId (lead-list id) or a configured defaultListId — list them with listSources().");
|
|
84
|
+
}
|
|
85
|
+
const max = opts.max ?? Number.POSITIVE_INFINITY;
|
|
86
|
+
const out = [];
|
|
87
|
+
let offset = 0;
|
|
88
|
+
while (out.length < max) {
|
|
89
|
+
const data = await call("/list/GetLeadsFromList", "POST", { listId, offset, limit: pageSize });
|
|
90
|
+
const items = toArray(data);
|
|
91
|
+
if (items.length === 0)
|
|
92
|
+
break;
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
out.push(normalizeHeyReachLead(item));
|
|
95
|
+
if (out.length >= max)
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
if (items.length < pageSize)
|
|
99
|
+
break;
|
|
100
|
+
offset += items.length;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** First value that is a non-empty string, else undefined (treats "" as absent). */
|
|
107
|
+
function firstNonEmpty(...values) {
|
|
108
|
+
for (const value of values) {
|
|
109
|
+
if (typeof value === "string" && value.trim() !== "")
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
/** HeyReach wraps list payloads as `{ items: [...] }` on some routes, bare arrays on others. */
|
|
115
|
+
function toArray(data) {
|
|
116
|
+
if (Array.isArray(data))
|
|
117
|
+
return data;
|
|
118
|
+
if (Array.isArray(data?.items))
|
|
119
|
+
return data.items;
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
/** Map a HeyReach lead (nested `profile` or flat) into the normalized shape. */
|
|
123
|
+
export function normalizeHeyReachLead(raw) {
|
|
124
|
+
const p = (raw && typeof raw === "object" && raw.profile) || raw || {};
|
|
125
|
+
const first = p.firstName ?? raw?.firstName ?? "";
|
|
126
|
+
const last = p.lastName ?? raw?.lastName ?? "";
|
|
127
|
+
const full = (p.fullName ?? raw?.fullName ?? `${first} ${last}`.trim()) || undefined;
|
|
128
|
+
return {
|
|
129
|
+
firstName: String(first ?? ""),
|
|
130
|
+
lastName: String(last ?? ""),
|
|
131
|
+
fullName: full,
|
|
132
|
+
headline: p.headline ?? raw?.headline ?? undefined,
|
|
133
|
+
jobTitle: p.position ?? p.jobTitle ?? raw?.position ?? undefined,
|
|
134
|
+
company: p.companyName ?? p.company ?? raw?.companyName ?? undefined,
|
|
135
|
+
profileUrl: String(p.profileUrl ?? p.linkedInUrl ?? raw?.profileUrl ?? raw?.linkedin_url ?? ""),
|
|
136
|
+
location: p.location ?? raw?.location ?? undefined,
|
|
137
|
+
// HeyReach exposes the raw `emailAddress` plus its own enrichment outputs
|
|
138
|
+
// (`enrichedEmailAddress`, `customEmailAddress`) — verified against the live
|
|
139
|
+
// /list/GetLeadsFromList schema 2026-06-20. Unresolved emails come back as
|
|
140
|
+
// "", so pick the first NON-EMPTY value (?? would keep the empty string).
|
|
141
|
+
email: firstNonEmpty(p.emailAddress, p.enrichedEmailAddress, p.customEmailAddress, p.email, raw?.emailAddress),
|
|
142
|
+
emailStatus: p.emailStatus ?? raw?.emailStatus ?? undefined,
|
|
143
|
+
raw,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// ── Fake provider (tests / dry-run with no key, no network) ─────────────────
|
|
147
|
+
export const FAKE_LINKEDIN_PROSPECTS = [
|
|
148
|
+
{
|
|
149
|
+
firstName: "Dana",
|
|
150
|
+
lastName: "Okafor",
|
|
151
|
+
fullName: "Dana Okafor",
|
|
152
|
+
headline: "VP RevOps",
|
|
153
|
+
jobTitle: "VP Revenue Operations",
|
|
154
|
+
company: "Northwind Logistics",
|
|
155
|
+
profileUrl: "https://www.linkedin.com/in/dana-okafor",
|
|
156
|
+
location: "Chicago, IL",
|
|
157
|
+
email: "dana@northwind.example",
|
|
158
|
+
emailStatus: "verified",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
firstName: "Priya",
|
|
162
|
+
lastName: "Raman",
|
|
163
|
+
fullName: "Priya Raman",
|
|
164
|
+
headline: "Head of Sales Ops",
|
|
165
|
+
jobTitle: "Head of Sales Operations",
|
|
166
|
+
company: "Cobalt Health",
|
|
167
|
+
profileUrl: "https://www.linkedin.com/in/priya-raman",
|
|
168
|
+
location: "Austin, TX",
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
firstName: "Marco",
|
|
172
|
+
lastName: "Bianchi",
|
|
173
|
+
fullName: "Marco Bianchi",
|
|
174
|
+
headline: "RevOps Manager",
|
|
175
|
+
jobTitle: "Revenue Operations Manager",
|
|
176
|
+
company: "Ferro Systems",
|
|
177
|
+
profileUrl: "https://www.linkedin.com/in/marco-bianchi",
|
|
178
|
+
location: "Remote",
|
|
179
|
+
email: "marco@ferro.example",
|
|
180
|
+
emailStatus: "guessed",
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
export function createFakeLinkedInProvider(opts = {}) {
|
|
184
|
+
const prospects = opts.prospects ?? FAKE_LINKEDIN_PROSPECTS;
|
|
185
|
+
const sources = opts.sources ?? [{ id: "list_demo", name: "Demo ICP list", count: prospects.length }];
|
|
186
|
+
return {
|
|
187
|
+
name: "fake",
|
|
188
|
+
async checkConnection() {
|
|
189
|
+
return opts.connection ?? { ok: true, detail: "fake LinkedIn provider" };
|
|
190
|
+
},
|
|
191
|
+
async listSources() {
|
|
192
|
+
return sources.map((s) => ({ ...s }));
|
|
193
|
+
},
|
|
194
|
+
async fetchProspects(options = {}) {
|
|
195
|
+
const capped = options.max != null ? prospects.slice(0, options.max) : prospects;
|
|
196
|
+
return capped.map((p) => ({ ...p }));
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API prospect sources for `enrich acquire` — net-new lead discovery + email
|
|
3
|
+
* resolution, behind one normalized shape the acquire builder consumes.
|
|
4
|
+
*
|
|
5
|
+
* Two confirmed providers (others slot in the same way):
|
|
6
|
+
* - Explorium — net-new discovery (POST /v1/prospects). Returns names, titles,
|
|
7
|
+
* company + website, LinkedIn; the email it returns is HASHED, so it is a
|
|
8
|
+
* discovery source, not an email source.
|
|
9
|
+
* - pipe0 — work-email resolution (POST /v1/pipes/run/sync with the
|
|
10
|
+
* `person:workemail:waterfall@1` block). Turns {name, company_domain} into a
|
|
11
|
+
* real work email. This is the email leg for Explorium-discovered people.
|
|
12
|
+
*
|
|
13
|
+
* Zero runtime deps: global fetch only, injectable for tests. Keys arrive via
|
|
14
|
+
* env/credential store, never argv.
|
|
15
|
+
*/
|
|
16
|
+
import type { CanonicalGtmSnapshot } from "../types.ts";
|
|
17
|
+
export type Prospect = {
|
|
18
|
+
firstName?: string;
|
|
19
|
+
lastName?: string;
|
|
20
|
+
fullName?: string;
|
|
21
|
+
jobTitle?: string;
|
|
22
|
+
/** normalized seniority, e.g. "cxo","vp","director" (Explorium job_level_main) */
|
|
23
|
+
jobLevel?: string;
|
|
24
|
+
/** normalized department, e.g. "sales" (Explorium job_department_main) */
|
|
25
|
+
jobDepartment?: string;
|
|
26
|
+
companyName?: string;
|
|
27
|
+
/** bare domain, e.g. "microsoft.com" — feeds pipe0's company_domain */
|
|
28
|
+
companyDomain?: string;
|
|
29
|
+
linkedin?: string;
|
|
30
|
+
/** real work email once resolved (pipe0); never the hashed value */
|
|
31
|
+
email?: string;
|
|
32
|
+
/** ICP fit score 0..1, set by the acquire scorer */
|
|
33
|
+
fitScore?: number;
|
|
34
|
+
/** provider-native id for traceability */
|
|
35
|
+
sourceId?: string;
|
|
36
|
+
};
|
|
37
|
+
type FetchImpl = typeof fetch;
|
|
38
|
+
export type ExploriumFilters = Record<string, {
|
|
39
|
+
values?: string[];
|
|
40
|
+
value?: boolean;
|
|
41
|
+
}>;
|
|
42
|
+
export declare function fetchExploriumProspects(opts: {
|
|
43
|
+
apiKey: string;
|
|
44
|
+
filters: ExploriumFilters;
|
|
45
|
+
size?: number;
|
|
46
|
+
apiBaseUrl?: string;
|
|
47
|
+
fetchImpl?: FetchImpl;
|
|
48
|
+
}): Promise<Prospect[]>;
|
|
49
|
+
export declare function fetchPipe0CrustdataProspects(opts: {
|
|
50
|
+
apiKey: string;
|
|
51
|
+
filters: Record<string, unknown>;
|
|
52
|
+
limit?: number;
|
|
53
|
+
apiBaseUrl?: string;
|
|
54
|
+
fetchImpl?: FetchImpl;
|
|
55
|
+
}): Promise<Prospect[]>;
|
|
56
|
+
/**
|
|
57
|
+
* Resolve real work emails for people via pipe0's waterfall. Input rows need a
|
|
58
|
+
* full name + a company domain (or name). Returns the prospects with `email`
|
|
59
|
+
* filled where the waterfall found one; rows with no hit are returned unchanged.
|
|
60
|
+
*/
|
|
61
|
+
export declare function pipe0ResolveWorkEmails(opts: {
|
|
62
|
+
apiKey: string;
|
|
63
|
+
prospects: Prospect[];
|
|
64
|
+
apiBaseUrl?: string;
|
|
65
|
+
fetchImpl?: FetchImpl;
|
|
66
|
+
/** Rows per pipe0 call. Small chunks are resilient to the waterfall's
|
|
67
|
+
* batch rate-limit (a throttled chunk returns work_email status "failed"
|
|
68
|
+
* for every row, so one big batch is all-or-nothing). Default 3. */
|
|
69
|
+
chunkSize?: number;
|
|
70
|
+
}): Promise<Prospect[]>;
|
|
71
|
+
/**
|
|
72
|
+
* Stable identity keys for a prospect (prefixed by kind). A match on ANY key
|
|
73
|
+
* means "same person". LinkedIn is strongest; name+domain is the only key
|
|
74
|
+
* shareable with the canonical CRM snapshot (which has no LinkedIn); email is
|
|
75
|
+
* available only after resolution (so it can't pre-filter, but it strengthens
|
|
76
|
+
* the seen cache).
|
|
77
|
+
*/
|
|
78
|
+
export declare function prospectIdentityKeys(p: Prospect): string[];
|
|
79
|
+
/** Identity keys for every contact already in the CRM snapshot (email + name|domain). */
|
|
80
|
+
export declare function crmContactKeys(snapshot: CanonicalGtmSnapshot): Set<string>;
|
|
81
|
+
/**
|
|
82
|
+
* Split prospects into those worth paying to enrich vs. drop. A prospect is
|
|
83
|
+
* dropped if any identity key is already in the CRM (`crmKeys`) or in the
|
|
84
|
+
* cross-run `seen` cache. Pure + deterministic.
|
|
85
|
+
*/
|
|
86
|
+
export declare function partitionFreshProspects(prospects: Prospect[], crmKeys: Set<string>, seen: Set<string>): {
|
|
87
|
+
fresh: Prospect[];
|
|
88
|
+
skippedCrm: number;
|
|
89
|
+
skippedSeen: number;
|
|
90
|
+
};
|
|
91
|
+
export {};
|