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
|
@@ -0,0 +1,324 @@
|
|
|
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
|
+
|
|
18
|
+
export type Prospect = {
|
|
19
|
+
firstName?: string;
|
|
20
|
+
lastName?: string;
|
|
21
|
+
fullName?: string;
|
|
22
|
+
jobTitle?: string;
|
|
23
|
+
/** normalized seniority, e.g. "cxo","vp","director" (Explorium job_level_main) */
|
|
24
|
+
jobLevel?: string;
|
|
25
|
+
/** normalized department, e.g. "sales" (Explorium job_department_main) */
|
|
26
|
+
jobDepartment?: string;
|
|
27
|
+
companyName?: string;
|
|
28
|
+
/** bare domain, e.g. "microsoft.com" — feeds pipe0's company_domain */
|
|
29
|
+
companyDomain?: string;
|
|
30
|
+
linkedin?: string;
|
|
31
|
+
/** real work email once resolved (pipe0); never the hashed value */
|
|
32
|
+
email?: string;
|
|
33
|
+
/** ICP fit score 0..1, set by the acquire scorer */
|
|
34
|
+
fitScore?: number;
|
|
35
|
+
/** provider-native id for traceability */
|
|
36
|
+
sourceId?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function splitName(full: string | undefined): { firstName?: string; lastName?: string } {
|
|
40
|
+
if (!full) return {};
|
|
41
|
+
const parts = full.trim().split(/\s+/);
|
|
42
|
+
if (parts.length === 1) return { firstName: parts[0] };
|
|
43
|
+
return { firstName: parts[0], lastName: parts.slice(1).join(" ") };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type FetchImpl = typeof fetch;
|
|
47
|
+
|
|
48
|
+
function bareDomain(value: string | undefined): string | undefined {
|
|
49
|
+
if (!value) return undefined;
|
|
50
|
+
return value
|
|
51
|
+
.trim()
|
|
52
|
+
.replace(/^https?:\/\//i, "")
|
|
53
|
+
.replace(/^www\./i, "")
|
|
54
|
+
.replace(/\/.*$/, "")
|
|
55
|
+
.toLowerCase() || undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Explorium — net-new discovery
|
|
60
|
+
|
|
61
|
+
export type ExploriumFilters = Record<string, { values?: string[]; value?: boolean }>;
|
|
62
|
+
|
|
63
|
+
export async function fetchExploriumProspects(opts: {
|
|
64
|
+
apiKey: string;
|
|
65
|
+
filters: ExploriumFilters;
|
|
66
|
+
size?: number;
|
|
67
|
+
apiBaseUrl?: string;
|
|
68
|
+
fetchImpl?: FetchImpl;
|
|
69
|
+
}): Promise<Prospect[]> {
|
|
70
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
71
|
+
const base = (opts.apiBaseUrl ?? "https://api.explorium.ai").replace(/\/$/, "");
|
|
72
|
+
const size = Math.min(opts.size ?? 25, 100);
|
|
73
|
+
const response = await fetchImpl(`${base}/v1/prospects`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error(`Explorium /v1/prospects failed: HTTP ${response.status} ${await safeText(response)}`);
|
|
80
|
+
}
|
|
81
|
+
const data = (await response.json()) as { data?: ExploriumRow[] };
|
|
82
|
+
return (data.data ?? []).map((row) => ({
|
|
83
|
+
firstName: row.first_name,
|
|
84
|
+
lastName: row.last_name,
|
|
85
|
+
fullName: row.full_name ?? ([row.first_name, row.last_name].filter(Boolean).join(" ") || undefined),
|
|
86
|
+
jobTitle: row.job_title,
|
|
87
|
+
jobLevel: row.job_level_main,
|
|
88
|
+
jobDepartment: row.job_department_main,
|
|
89
|
+
companyName: row.company_name,
|
|
90
|
+
companyDomain: bareDomain(row.company_website),
|
|
91
|
+
linkedin: normalizeLinkedin(row.linkedin),
|
|
92
|
+
sourceId: row.prospect_id,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type ExploriumRow = {
|
|
97
|
+
prospect_id?: string;
|
|
98
|
+
first_name?: string;
|
|
99
|
+
last_name?: string;
|
|
100
|
+
full_name?: string;
|
|
101
|
+
job_title?: string;
|
|
102
|
+
job_level_main?: string;
|
|
103
|
+
job_department_main?: string;
|
|
104
|
+
company_name?: string;
|
|
105
|
+
company_website?: string;
|
|
106
|
+
linkedin?: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// pipe0 / Crustdata — net-new discovery (search). Crustdata is built into pipe0
|
|
111
|
+
// (no separate connection needed). Returns profiles; pair with
|
|
112
|
+
// pipe0ResolveWorkEmails to get real emails.
|
|
113
|
+
|
|
114
|
+
export async function fetchPipe0CrustdataProspects(opts: {
|
|
115
|
+
apiKey: string;
|
|
116
|
+
filters: Record<string, unknown>;
|
|
117
|
+
limit?: number;
|
|
118
|
+
apiBaseUrl?: string;
|
|
119
|
+
fetchImpl?: FetchImpl;
|
|
120
|
+
}): Promise<Prospect[]> {
|
|
121
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
122
|
+
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
123
|
+
const limit = Math.min(opts.limit ?? 25, 100);
|
|
124
|
+
const response = await fetchImpl(`${base}/v1/searches/run/sync`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
searches: [{ search_id: "people:profiles:crustdata@1", config: { limit, filters: opts.filters } }],
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
throw new Error(`pipe0 /v1/searches/run/sync failed: HTTP ${response.status} ${await safeText(response)}`);
|
|
133
|
+
}
|
|
134
|
+
const body = (await response.json()) as {
|
|
135
|
+
results?: Array<Record<string, { value?: unknown }>>;
|
|
136
|
+
search_statuses?: Array<{ errors?: Array<{ code?: string; message?: string }> }>;
|
|
137
|
+
};
|
|
138
|
+
// Surface upstream provider errors (e.g. CreditBalanceInsufficient) instead of
|
|
139
|
+
// silently returning [] — a throttle/credit failure must not look like "no ICP matches".
|
|
140
|
+
const upstreamErrors = (body.search_statuses ?? []).flatMap((s) => s.errors ?? []);
|
|
141
|
+
if (upstreamErrors.length > 0 && !(body.results ?? []).length) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`pipe0/Crustdata search error: ${upstreamErrors.map((e) => `${e.code ?? "error"}: ${e.message ?? ""}`).join("; ")}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return (body.results ?? []).map((row) => {
|
|
147
|
+
const fullName = strField(row.name);
|
|
148
|
+
const { firstName, lastName } = splitName(fullName);
|
|
149
|
+
return {
|
|
150
|
+
firstName,
|
|
151
|
+
lastName,
|
|
152
|
+
fullName,
|
|
153
|
+
jobTitle: strField(row.job_title),
|
|
154
|
+
companyDomain: bareDomain(strField(row.company_website_url)),
|
|
155
|
+
linkedin: normalizeLinkedin(strField(row.profile_url)),
|
|
156
|
+
sourceId: strField(row.profile_url) ?? fullName,
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function strField(field: { value?: unknown } | undefined): string | undefined {
|
|
162
|
+
const v = field?.value;
|
|
163
|
+
return typeof v === "string" && v.trim() ? v : undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeLinkedin(value: string | undefined): string | undefined {
|
|
167
|
+
if (!value) return undefined;
|
|
168
|
+
const v = value.trim().replace(/^https?:\/\//i, "").replace(/\/$/, "");
|
|
169
|
+
return v ? `https://${v}` : undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// pipe0 — work-email resolution (waterfall)
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve real work emails for people via pipe0's waterfall. Input rows need a
|
|
177
|
+
* full name + a company domain (or name). Returns the prospects with `email`
|
|
178
|
+
* filled where the waterfall found one; rows with no hit are returned unchanged.
|
|
179
|
+
*/
|
|
180
|
+
export async function pipe0ResolveWorkEmails(opts: {
|
|
181
|
+
apiKey: string;
|
|
182
|
+
prospects: Prospect[];
|
|
183
|
+
apiBaseUrl?: string;
|
|
184
|
+
fetchImpl?: FetchImpl;
|
|
185
|
+
/** Rows per pipe0 call. Small chunks are resilient to the waterfall's
|
|
186
|
+
* batch rate-limit (a throttled chunk returns work_email status "failed"
|
|
187
|
+
* for every row, so one big batch is all-or-nothing). Default 3. */
|
|
188
|
+
chunkSize?: number;
|
|
189
|
+
}): Promise<Prospect[]> {
|
|
190
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
191
|
+
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
192
|
+
const chunkSize = Math.max(1, opts.chunkSize ?? 3);
|
|
193
|
+
|
|
194
|
+
// Only rows with the waterfall's required inputs (name + company_domain|name).
|
|
195
|
+
const resolvable = opts.prospects.filter((p) => p.fullName && (p.companyDomain || p.companyName));
|
|
196
|
+
if (resolvable.length === 0) return opts.prospects;
|
|
197
|
+
|
|
198
|
+
const emailByKey = new Map<string, string>();
|
|
199
|
+
for (let i = 0; i < resolvable.length; i += chunkSize) {
|
|
200
|
+
const chunk = resolvable.slice(i, i + chunkSize);
|
|
201
|
+
const input = chunk.map((p) => ({
|
|
202
|
+
name: p.fullName,
|
|
203
|
+
...(p.companyDomain ? { company_domain: p.companyDomain } : { company_name: p.companyName }),
|
|
204
|
+
}));
|
|
205
|
+
let body: Pipe0RunResponse;
|
|
206
|
+
try {
|
|
207
|
+
const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
210
|
+
body: JSON.stringify({ pipes: [{ pipe_id: "person:workemail:waterfall@1" }], input }),
|
|
211
|
+
});
|
|
212
|
+
if (!response.ok) continue; // throttled/5xx chunk — skip, keep the rest
|
|
213
|
+
body = (await response.json()) as Pipe0RunResponse;
|
|
214
|
+
} catch {
|
|
215
|
+
continue; // network hiccup on one chunk must not sink the whole run
|
|
216
|
+
}
|
|
217
|
+
for (const recordId of body.order ?? []) {
|
|
218
|
+
const fields = body.records?.[recordId]?.fields ?? {};
|
|
219
|
+
const email = fields.work_email?.status === "completed" ? fieldValue(fields.work_email) : undefined;
|
|
220
|
+
if (email) {
|
|
221
|
+
const domain = fieldValue(fields.company_domain) ?? fieldValue(fields.company_name);
|
|
222
|
+
emailByKey.set(personKey(fieldValue(fields.name), domain), email);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return opts.prospects.map((p) => {
|
|
227
|
+
const email = emailByKey.get(personKey(p.fullName, p.companyDomain ?? p.companyName));
|
|
228
|
+
return email ? { ...p, email } : p;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function personKey(name: string | undefined, company: string | undefined): string {
|
|
233
|
+
return `${(name ?? "").trim().toLowerCase()}|${(company ?? "").trim().toLowerCase()}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
type Pipe0Field = { value?: unknown; status?: string };
|
|
237
|
+
type Pipe0RunResponse = {
|
|
238
|
+
order?: string[];
|
|
239
|
+
records?: Record<string, { fields?: Record<string, Pipe0Field> }>;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
function fieldValue(field: Pipe0Field | undefined): string | undefined {
|
|
243
|
+
const v = field?.value;
|
|
244
|
+
return typeof v === "string" && v.trim() ? v : undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function safeText(response: Response): Promise<string> {
|
|
248
|
+
try {
|
|
249
|
+
return (await response.text()).slice(0, 300);
|
|
250
|
+
} catch {
|
|
251
|
+
return "";
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Pre-email dedup: identity keys shared between prospects and CRM contacts, so
|
|
257
|
+
// we can drop already-in-CRM / already-seen people BEFORE paying for emails.
|
|
258
|
+
|
|
259
|
+
function normName(value: string | undefined): string {
|
|
260
|
+
return (value ?? "").trim().toLowerCase().replace(/\s+/g, " ");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Stable identity keys for a prospect (prefixed by kind). A match on ANY key
|
|
265
|
+
* means "same person". LinkedIn is strongest; name+domain is the only key
|
|
266
|
+
* shareable with the canonical CRM snapshot (which has no LinkedIn); email is
|
|
267
|
+
* available only after resolution (so it can't pre-filter, but it strengthens
|
|
268
|
+
* the seen cache).
|
|
269
|
+
*/
|
|
270
|
+
export function prospectIdentityKeys(p: Prospect): string[] {
|
|
271
|
+
const keys: string[] = [];
|
|
272
|
+
if (p.linkedin) keys.push(`li:${p.linkedin.trim().toLowerCase().replace(/\/+$/, "")}`);
|
|
273
|
+
const name = normName(p.fullName ?? [p.firstName, p.lastName].filter(Boolean).join(" "));
|
|
274
|
+
const domain = bareDomain(p.companyDomain);
|
|
275
|
+
if (name && domain) keys.push(`nd:${name}|${domain}`);
|
|
276
|
+
if (p.email) keys.push(`em:${p.email.trim().toLowerCase()}`);
|
|
277
|
+
return keys;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Identity keys for every contact already in the CRM snapshot (email + name|domain). */
|
|
281
|
+
export function crmContactKeys(snapshot: CanonicalGtmSnapshot): Set<string> {
|
|
282
|
+
const domainByAccount = new Map<string, string>();
|
|
283
|
+
for (const account of snapshot.accounts ?? []) {
|
|
284
|
+
const d = bareDomain(account.domain);
|
|
285
|
+
if (d) domainByAccount.set(account.id, d);
|
|
286
|
+
}
|
|
287
|
+
const set = new Set<string>();
|
|
288
|
+
for (const contact of snapshot.contacts ?? []) {
|
|
289
|
+
if (contact.linkedin) set.add(`li:${contact.linkedin.trim().toLowerCase().replace(/\/+$/, "")}`);
|
|
290
|
+
if (contact.email) set.add(`em:${contact.email.trim().toLowerCase()}`);
|
|
291
|
+
const name = normName([contact.firstName, contact.lastName].filter(Boolean).join(" "));
|
|
292
|
+
const domain = contact.accountId ? domainByAccount.get(contact.accountId) : undefined;
|
|
293
|
+
if (name && domain) set.add(`nd:${name}|${domain}`);
|
|
294
|
+
}
|
|
295
|
+
return set;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Split prospects into those worth paying to enrich vs. drop. A prospect is
|
|
300
|
+
* dropped if any identity key is already in the CRM (`crmKeys`) or in the
|
|
301
|
+
* cross-run `seen` cache. Pure + deterministic.
|
|
302
|
+
*/
|
|
303
|
+
export function partitionFreshProspects(
|
|
304
|
+
prospects: Prospect[],
|
|
305
|
+
crmKeys: Set<string>,
|
|
306
|
+
seen: Set<string>,
|
|
307
|
+
): { fresh: Prospect[]; skippedCrm: number; skippedSeen: number } {
|
|
308
|
+
const fresh: Prospect[] = [];
|
|
309
|
+
let skippedCrm = 0;
|
|
310
|
+
let skippedSeen = 0;
|
|
311
|
+
for (const p of prospects) {
|
|
312
|
+
const keys = prospectIdentityKeys(p);
|
|
313
|
+
if (keys.some((k) => crmKeys.has(k))) {
|
|
314
|
+
skippedCrm += 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (keys.some((k) => seen.has(k))) {
|
|
318
|
+
skippedSeen += 1;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
fresh.push(p);
|
|
322
|
+
}
|
|
323
|
+
return { fresh, skippedCrm, skippedSeen };
|
|
324
|
+
}
|