fullstackgtm 0.33.0 → 0.37.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 +102 -0
- package/README.md +25 -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/cli.js +732 -21
- package/dist/connectors/hubspot.js +135 -0
- package/dist/connectors/prospectSources.d.ts +91 -0
- package/dist/connectors/prospectSources.js +227 -0
- package/dist/enrich.d.ts +87 -0
- package/dist/enrich.js +188 -4
- 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 -1
- package/dist/index.js +2 -1
- package/dist/mappings.js +3 -0
- package/dist/marketSourcing.d.ts +25 -0
- package/dist/marketSourcing.js +82 -0
- package/dist/types.d.ts +17 -1
- package/docs/api.md +29 -2
- package/docs/architecture.md +8 -1
- package/docs/dx-punch-list.md +87 -0
- package/llms.txt +16 -0
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +2 -1
- package/src/acquireMeter.ts +186 -0
- package/src/acquireSeen.ts +57 -0
- package/src/cli.ts +870 -20
- package/src/connectors/hubspot.ts +140 -0
- package/src/connectors/prospectSources.ts +324 -0
- package/src/enrich.ts +275 -3
- package/src/format.ts +20 -2
- package/src/health.ts +238 -0
- package/src/icp.ts +313 -0
- package/src/index.ts +11 -0
- package/src/mappings.ts +3 -0
- package/src/marketSourcing.ts +104 -0
- package/src/types.ts +24 -0
package/src/icp.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICP — the Ideal Customer Profile that makes `enrich acquire` targeted instead
|
|
3
|
+
* of random. One structured artifact drives two things:
|
|
4
|
+
* 1. discovery filters — translated per provider (Explorium, pipe0/Crustdata)
|
|
5
|
+
* so the pull only returns ICP-shaped companies + people, and
|
|
6
|
+
* 2. fit scoring — every discovered prospect is scored against the persona so
|
|
7
|
+
* only above-threshold leads become create_record ops.
|
|
8
|
+
*
|
|
9
|
+
* Develop one via interview: an agent (Claude Code / Codex) reads INTERVIEW_SPEC,
|
|
10
|
+
* asks the questions with its AskUserQuestion tool, and writes the answers into
|
|
11
|
+
* an Icp (CLI: `icp interview` emits the spec, `icp show` renders the result).
|
|
12
|
+
*
|
|
13
|
+
* Zero runtime deps; pure functions (translation + scoring take plain data).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type Icp = {
|
|
17
|
+
name: string;
|
|
18
|
+
firmographics: {
|
|
19
|
+
/** human industry labels, e.g. ["software","saas"] — used for keyword/industry filters */
|
|
20
|
+
industries?: string[];
|
|
21
|
+
/** Explorium naics_category codes, e.g. ["5112"] (software publishers) */
|
|
22
|
+
naics?: string[];
|
|
23
|
+
/** provider-agnostic employee bands: "1-10","11-50","51-200","201-500","501-1000","1001-5000","5001-10000","10001+" */
|
|
24
|
+
employeeBands?: string[];
|
|
25
|
+
/** ISO country codes, lowercased, e.g. ["us"] */
|
|
26
|
+
geos?: string[];
|
|
27
|
+
};
|
|
28
|
+
persona: {
|
|
29
|
+
/** seniority: "cxo","vp","director","manager","owner","senior" */
|
|
30
|
+
jobLevels?: string[];
|
|
31
|
+
/** "sales","marketing","operations","finance" */
|
|
32
|
+
departments?: string[];
|
|
33
|
+
/** the title phrases that DEFINE the buyer — also the scoring signal */
|
|
34
|
+
titleKeywords?: string[];
|
|
35
|
+
};
|
|
36
|
+
signals?: { intentTopics?: string[] };
|
|
37
|
+
scoring?: {
|
|
38
|
+
/** minimum fit (0..1) for a prospect to become a create_record op. Default 0.5. */
|
|
39
|
+
threshold?: number;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_FIT_THRESHOLD = 0.5;
|
|
44
|
+
|
|
45
|
+
const COUNTRY_NAMES: Record<string, string> = {
|
|
46
|
+
us: "United States",
|
|
47
|
+
ca: "Canada",
|
|
48
|
+
gb: "United Kingdom",
|
|
49
|
+
uk: "United Kingdom",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function parseIcp(raw: string): Icp {
|
|
53
|
+
let parsed: unknown;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(raw);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new Error(`icp: not valid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
58
|
+
}
|
|
59
|
+
if (!parsed || typeof parsed !== "object") throw new Error("icp: expected a JSON object");
|
|
60
|
+
const icp = parsed as Icp;
|
|
61
|
+
if (!icp.name || typeof icp.name !== "string") throw new Error('icp: missing "name"');
|
|
62
|
+
if (!icp.persona || (!icp.persona.titleKeywords?.length && !icp.persona.jobLevels?.length)) {
|
|
63
|
+
throw new Error('icp: "persona" needs at least titleKeywords or jobLevels (without them, scoring cannot rank fit)');
|
|
64
|
+
}
|
|
65
|
+
return icp;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Discovery-filter translation
|
|
70
|
+
|
|
71
|
+
/** Explorium /v1/prospects filters from the ICP. */
|
|
72
|
+
export function icpToExploriumFilters(icp: Icp): Record<string, { values?: string[]; value?: boolean }> {
|
|
73
|
+
const f: Record<string, { values?: string[]; value?: boolean }> = { has_email: { value: true } };
|
|
74
|
+
if (icp.persona.jobLevels?.length) f.job_level = { values: icp.persona.jobLevels };
|
|
75
|
+
if (icp.persona.departments?.length) f.job_department = { values: icp.persona.departments };
|
|
76
|
+
if (icp.firmographics.employeeBands?.length) f.company_size = { values: icp.firmographics.employeeBands };
|
|
77
|
+
if (icp.firmographics.geos?.length) f.company_country_code = { values: icp.firmographics.geos };
|
|
78
|
+
if (icp.firmographics.naics?.length) f.naics_category = { values: icp.firmographics.naics };
|
|
79
|
+
return f;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Crustdata / LinkedIn seniority vocab. ICP job levels are normalized lowercase;
|
|
84
|
+
* Crustdata expects these exact capitalized strings (verified: "CXO",
|
|
85
|
+
* "Vice President", "Director" appear in Crustdata's docs/examples).
|
|
86
|
+
*/
|
|
87
|
+
const CRUSTDATA_SENIORITY: Record<string, string> = {
|
|
88
|
+
cxo: "CXO",
|
|
89
|
+
"c-suite": "CXO",
|
|
90
|
+
c_suite: "CXO",
|
|
91
|
+
founder: "Owner",
|
|
92
|
+
owner: "Owner",
|
|
93
|
+
partner: "Partner",
|
|
94
|
+
vp: "Vice President",
|
|
95
|
+
"vice president": "Vice President",
|
|
96
|
+
head: "Director",
|
|
97
|
+
director: "Director",
|
|
98
|
+
manager: "Manager",
|
|
99
|
+
senior: "Senior",
|
|
100
|
+
entry: "Entry",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* ICP industry keyword → LinkedIn industry names Crustdata filters on. Includes
|
|
105
|
+
* the current LinkedIn-v2 names; for software we send the cluster (dev + IT
|
|
106
|
+
* services + internet) so an OR match isn't overly narrow.
|
|
107
|
+
*/
|
|
108
|
+
const CRUSTDATA_INDUSTRY: Record<string, string[]> = {
|
|
109
|
+
software: ["Software Development", "Information Technology and Services", "Internet"],
|
|
110
|
+
saas: ["Software Development", "Information Technology and Services"],
|
|
111
|
+
"information technology & services": ["Information Technology and Services"],
|
|
112
|
+
"information technology and services": ["Information Technology and Services"],
|
|
113
|
+
internet: ["Internet"],
|
|
114
|
+
fintech: ["Financial Services"],
|
|
115
|
+
"financial services": ["Financial Services"],
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
|
|
120
|
+
* matches real LinkedIn title strings (case-sensitive), so keywords are Title
|
|
121
|
+
* Cased. Seniority + industry are mapped to Crustdata's controlled vocab via the
|
|
122
|
+
* tables above. NOTE: the exact pipe0→Crustdata value set could not be re-run
|
|
123
|
+
* live (pipe0 credits were exhausted) — validate when credits refill; fit
|
|
124
|
+
* scoring is the safety net for persona precision regardless.
|
|
125
|
+
*/
|
|
126
|
+
export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
|
|
127
|
+
const f: Record<string, unknown> = {};
|
|
128
|
+
if (icp.persona.titleKeywords?.length) f.current_job_titles = icp.persona.titleKeywords.map(titleCase);
|
|
129
|
+
if (icp.firmographics.geos?.length) {
|
|
130
|
+
f.locations = icp.firmographics.geos.map((g) => COUNTRY_NAMES[g.toLowerCase()] ?? g);
|
|
131
|
+
}
|
|
132
|
+
if (icp.persona.jobLevels?.length) {
|
|
133
|
+
const include = [...new Set(icp.persona.jobLevels.map((l) => CRUSTDATA_SENIORITY[l.toLowerCase()]).filter(Boolean))];
|
|
134
|
+
if (include.length) f.current_seniority_levels = { include, exclude: [] };
|
|
135
|
+
}
|
|
136
|
+
if (icp.firmographics.industries?.length) {
|
|
137
|
+
const inds = [
|
|
138
|
+
...new Set(icp.firmographics.industries.flatMap((i) => CRUSTDATA_INDUSTRY[i.toLowerCase()] ?? [titleCase(i)])),
|
|
139
|
+
];
|
|
140
|
+
if (inds.length) f.current_employers_linkedin_industries = inds;
|
|
141
|
+
}
|
|
142
|
+
return f;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const ACRONYMS = new Set(["cro", "coo", "ceo", "cfo", "vp", "crm", "gtm", "saas"]);
|
|
146
|
+
function titleCase(value: string): string {
|
|
147
|
+
return value
|
|
148
|
+
.split(/\s+/)
|
|
149
|
+
.map((w) => (ACRONYMS.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)))
|
|
150
|
+
.join(" ");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Fit scoring (persona precision; firmographics are enforced by the filter)
|
|
155
|
+
|
|
156
|
+
export type IcpFit = { score: number; reasons: string[] };
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Score a prospect's PERSONA fit 0..1. Title-keyword match is the strongest
|
|
160
|
+
* signal (it's what defines the buyer); job level and department add to it.
|
|
161
|
+
* Firmographics aren't re-scored here — discovery already filtered on them.
|
|
162
|
+
*/
|
|
163
|
+
export function scoreProspectAgainstIcp(
|
|
164
|
+
prospect: { jobTitle?: string; jobLevel?: string; jobDepartment?: string },
|
|
165
|
+
icp: Icp,
|
|
166
|
+
): IcpFit {
|
|
167
|
+
const reasons: string[] = [];
|
|
168
|
+
const title = (prospect.jobTitle ?? "").toLowerCase();
|
|
169
|
+
const keywords = (icp.persona.titleKeywords ?? []).map((k) => k.toLowerCase());
|
|
170
|
+
const levels = (icp.persona.jobLevels ?? []).map((l) => l.toLowerCase());
|
|
171
|
+
const depts = (icp.persona.departments ?? []).map((d) => d.toLowerCase());
|
|
172
|
+
|
|
173
|
+
let score = 0;
|
|
174
|
+
let weightSum = 0;
|
|
175
|
+
|
|
176
|
+
if (keywords.length) {
|
|
177
|
+
weightSum += 0.6;
|
|
178
|
+
const hit = keywords.find((k) => title.includes(k));
|
|
179
|
+
if (hit) {
|
|
180
|
+
score += 0.6;
|
|
181
|
+
reasons.push(`title matches ICP keyword "${hit}"`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (levels.length) {
|
|
185
|
+
weightSum += 0.25;
|
|
186
|
+
const level = (prospect.jobLevel ?? "").toLowerCase();
|
|
187
|
+
if (level && levels.some((l) => level.includes(l))) {
|
|
188
|
+
score += 0.25;
|
|
189
|
+
reasons.push(`seniority "${prospect.jobLevel}" in ICP levels`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (depts.length) {
|
|
193
|
+
weightSum += 0.15;
|
|
194
|
+
const dept = (prospect.jobDepartment ?? "").toLowerCase();
|
|
195
|
+
if (dept && depts.some((d) => dept.includes(d))) {
|
|
196
|
+
score += 0.15;
|
|
197
|
+
reasons.push(`department "${prospect.jobDepartment}" in ICP`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const normalized = weightSum > 0 ? score / weightSum : 0;
|
|
201
|
+
if (reasons.length === 0) reasons.push("no persona signal matched the ICP");
|
|
202
|
+
return { score: Number(normalized.toFixed(3)), reasons };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function fitThreshold(icp: Icp): number {
|
|
206
|
+
return icp.scoring?.threshold ?? DEFAULT_FIT_THRESHOLD;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Interview spec — an agent drives this with AskUserQuestion, then writes an Icp
|
|
211
|
+
|
|
212
|
+
export type IcpInterviewQuestion = {
|
|
213
|
+
id: keyof FlatIcpAnswers;
|
|
214
|
+
header: string;
|
|
215
|
+
question: string;
|
|
216
|
+
multiSelect: boolean;
|
|
217
|
+
options: { label: string; value: string | string[]; description: string }[];
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** Flattened answer keys the interview collects (assembled into an Icp). */
|
|
221
|
+
export type FlatIcpAnswers = {
|
|
222
|
+
industries: string[];
|
|
223
|
+
employeeBands: string[];
|
|
224
|
+
jobLevels: string[];
|
|
225
|
+
departments: string[];
|
|
226
|
+
titleKeywords: string[];
|
|
227
|
+
geos: string[];
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export const INTERVIEW_SPEC: IcpInterviewQuestion[] = [
|
|
231
|
+
{
|
|
232
|
+
id: "industries",
|
|
233
|
+
header: "Target market",
|
|
234
|
+
question: "What company profile are you targeting? (Drives firmographic discovery filters.)",
|
|
235
|
+
multiSelect: true,
|
|
236
|
+
options: [
|
|
237
|
+
{ label: "B2B SaaS / software", value: ["software", "saas"], description: "Software & internet — classic messy-CRM, RevOps-equipped buyer." },
|
|
238
|
+
{ label: "Broader tech-enabled B2B", value: ["software", "information technology & services", "financial services"], description: "Any B2B running a real sales motion on a CRM." },
|
|
239
|
+
{ label: "Any B2B with a CRM", value: [], description: "Vertical-agnostic — qualify on persona, not industry." },
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: "employeeBands",
|
|
244
|
+
header: "Company size",
|
|
245
|
+
question: "Company size (employee bands) that fit?",
|
|
246
|
+
multiSelect: true,
|
|
247
|
+
options: [
|
|
248
|
+
{ label: "Seed–Series A (1–50)", value: ["1-10", "11-50"], description: "Early; often no dedicated RevOps yet." },
|
|
249
|
+
{ label: "Series B–C (51–500)", value: ["51-200", "201-500"], description: "Sweet spot: messy CRM, RevOps exists, budget exists." },
|
|
250
|
+
{ label: "Growth (501–2000)", value: ["501-1000", "1001-5000"], description: "Established RevOps, many CRM writers." },
|
|
251
|
+
{ label: "Enterprise (2000+)", value: ["1001-5000", "5001-10000", "10001+"], description: "Up-market; longer cycles." },
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: "titleKeywords",
|
|
256
|
+
header: "Buyer persona",
|
|
257
|
+
question: "Which buyer persona(s) should discovery target and scoring reward?",
|
|
258
|
+
multiSelect: true,
|
|
259
|
+
options: [
|
|
260
|
+
{ label: "RevOps leadership", value: ["revenue operations", "revops", "head of revenue operations"], description: "VP/Head/Dir Revenue Operations." },
|
|
261
|
+
{ label: "Sales/Marketing Ops", value: ["sales operations", "gtm operations", "marketing operations", "revenue ops"], description: "Hands-on the CRM daily." },
|
|
262
|
+
{ label: "Exec buyers", value: ["chief revenue officer", "cro", "chief operating officer", "vp of sales"], description: "Economic sign-off." },
|
|
263
|
+
{ label: "CRM/SF admins", value: ["salesforce admin", "hubspot admin", "crm manager", "revops analyst"], description: "Feel the pain, champion the fix." },
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
id: "geos",
|
|
268
|
+
header: "Geography",
|
|
269
|
+
question: "Geography?",
|
|
270
|
+
multiSelect: true,
|
|
271
|
+
options: [
|
|
272
|
+
{ label: "United States", value: ["us"], description: "US-only (best data coverage)." },
|
|
273
|
+
{ label: "US + Canada", value: ["us", "ca"], description: "North America." },
|
|
274
|
+
{ label: "US + UK/EU", value: ["us", "gb"], description: "English-speaking + Western Europe (mind GDPR)." },
|
|
275
|
+
{ label: "Global", value: [], description: "No geo filter." },
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
/** Assemble an Icp from flattened interview answers. */
|
|
281
|
+
export function icpFromAnswers(name: string, answers: FlatIcpAnswers): Icp {
|
|
282
|
+
const levelsFromTitles = inferLevels(answers.titleKeywords);
|
|
283
|
+
return {
|
|
284
|
+
name,
|
|
285
|
+
firmographics: {
|
|
286
|
+
industries: dedupe(answers.industries),
|
|
287
|
+
naics: answers.industries.some((i) => /software|saas/i.test(i)) ? ["5112"] : undefined,
|
|
288
|
+
employeeBands: dedupe(answers.employeeBands),
|
|
289
|
+
geos: dedupe(answers.geos),
|
|
290
|
+
},
|
|
291
|
+
persona: {
|
|
292
|
+
jobLevels: answers.jobLevels?.length ? dedupe(answers.jobLevels) : levelsFromTitles,
|
|
293
|
+
departments: answers.departments?.length ? dedupe(answers.departments) : ["sales", "marketing", "operations"],
|
|
294
|
+
titleKeywords: dedupe(answers.titleKeywords),
|
|
295
|
+
},
|
|
296
|
+
scoring: { threshold: DEFAULT_FIT_THRESHOLD },
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function inferLevels(titles: string[]): string[] {
|
|
301
|
+
const levels = new Set<string>();
|
|
302
|
+
for (const t of titles.map((x) => x.toLowerCase())) {
|
|
303
|
+
if (/chief|^c[a-z]o$|cro|coo/.test(t)) levels.add("cxo");
|
|
304
|
+
if (/vp|vice president|head of/.test(t)) levels.add("vp");
|
|
305
|
+
if (/director/.test(t)) levels.add("director");
|
|
306
|
+
if (/manager|admin|analyst|operations/.test(t)) levels.add("manager");
|
|
307
|
+
}
|
|
308
|
+
return levels.size ? [...levels] : ["cxo", "vp", "director", "manager"];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function dedupe(values: string[] | undefined): string[] {
|
|
312
|
+
return [...new Set((values ?? []).filter(Boolean))];
|
|
313
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -131,6 +131,14 @@ export {
|
|
|
131
131
|
type AuditLogVerification,
|
|
132
132
|
} from "./auditLog.ts";
|
|
133
133
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
134
|
+
export {
|
|
135
|
+
computeHealth,
|
|
136
|
+
summarizeHealth,
|
|
137
|
+
healthToMarkdown,
|
|
138
|
+
type HealthEntry,
|
|
139
|
+
type HealthRollup,
|
|
140
|
+
type HealthRuleDelta,
|
|
141
|
+
} from "./health.ts";
|
|
134
142
|
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
135
143
|
export {
|
|
136
144
|
HUBSPOT_DEFAULT_FIELD_MAPPINGS,
|
|
@@ -301,9 +309,12 @@ export {
|
|
|
301
309
|
findCategoryPageInSitemap,
|
|
302
310
|
findCategoryPage,
|
|
303
311
|
fetchLogoDataUri,
|
|
312
|
+
discoverCompetitors,
|
|
304
313
|
type FetchText,
|
|
305
314
|
type FetchBytes,
|
|
306
315
|
type ResolveUrl,
|
|
316
|
+
type DiscoveredVendor,
|
|
317
|
+
type DiscoverCompetitorsOptions,
|
|
307
318
|
} from "./marketSourcing.ts";
|
|
308
319
|
export {
|
|
309
320
|
computeMissedFirings,
|
package/src/mappings.ts
CHANGED
|
@@ -22,6 +22,9 @@ export const HUBSPOT_DEFAULT_FIELD_MAPPINGS: Record<
|
|
|
22
22
|
email: "email",
|
|
23
23
|
phone: "phone",
|
|
24
24
|
title: "jobtitle",
|
|
25
|
+
// HubSpot-standard "LinkedIn URL"; safe to request everywhere (HubSpot
|
|
26
|
+
// ignores unknown properties rather than erroring). Powers strong dedup.
|
|
27
|
+
linkedin: "hs_linkedin_url",
|
|
25
28
|
ownerId: "hubspot_owner_id",
|
|
26
29
|
},
|
|
27
30
|
deals: {
|
package/src/marketSourcing.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* headless browser, so the default fetch is sufficient even in the hosted layer.
|
|
18
18
|
*/
|
|
19
19
|
import { assertPublicUrl } from "./market.ts";
|
|
20
|
+
import { DEFAULT_MODELS, forcedToolCall, type LlmCallOptions } from "./llm.ts";
|
|
20
21
|
|
|
21
22
|
const USER_AGENT = "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)";
|
|
22
23
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
@@ -403,3 +404,106 @@ export async function fetchLogoDataUri(
|
|
|
403
404
|
}
|
|
404
405
|
return null;
|
|
405
406
|
}
|
|
407
|
+
|
|
408
|
+
// ── Competitor discovery ─────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
/** A vendor proposed by `discoverCompetitors`. */
|
|
411
|
+
export type DiscoveredVendor = {
|
|
412
|
+
name: string;
|
|
413
|
+
/** Canonical homepage. */
|
|
414
|
+
url: string;
|
|
415
|
+
/** The page most specific to the category — the product page for multi-product
|
|
416
|
+
* companies, else the homepage. Use as the capture seed. */
|
|
417
|
+
productUrl: string;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
export type DiscoverCompetitorsOptions = {
|
|
421
|
+
llm: LlmCallOptions;
|
|
422
|
+
/** The user's own company: competitors are listed for it and it's excluded. */
|
|
423
|
+
anchorUrl?: string;
|
|
424
|
+
/** Vendor hosts already in the set, to exclude from results. */
|
|
425
|
+
exclude?: string[];
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const DISCOVERY_SCHEMA = {
|
|
429
|
+
type: "object",
|
|
430
|
+
required: ["competitors"],
|
|
431
|
+
properties: {
|
|
432
|
+
competitors: {
|
|
433
|
+
type: "array",
|
|
434
|
+
description: "Real vendors competing in this category today, each with its canonical https homepage URL.",
|
|
435
|
+
items: {
|
|
436
|
+
type: "object",
|
|
437
|
+
required: ["name", "url"],
|
|
438
|
+
properties: {
|
|
439
|
+
name: { type: "string" },
|
|
440
|
+
url: { type: "string", description: "Canonical homepage, https://domain, no tracking path." },
|
|
441
|
+
productUrl: {
|
|
442
|
+
type: "string",
|
|
443
|
+
description:
|
|
444
|
+
"URL of the page on THIS vendor's site most specific to the category. For a multi-product company (e.g. SAP, Oracle) this is the product/solution page for this category, NOT the corporate homepage. For a focused single-product vendor, repeat the homepage.",
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Propose the real vendor set for a category via the LLM — so a cold-start map
|
|
454
|
+
* needs only a category, not a hand-built vendor list. Returns canonical homepages
|
|
455
|
+
* plus a category-specific `productUrl` per vendor; excludes the anchor + any
|
|
456
|
+
* supplied hosts, de-dupes by registrable domain, and instructs the model to skip
|
|
457
|
+
* acquired/defunct brands. BYOK via the package's `forcedToolCall`.
|
|
458
|
+
*/
|
|
459
|
+
export async function discoverCompetitors(
|
|
460
|
+
category: string,
|
|
461
|
+
options: DiscoverCompetitorsOptions,
|
|
462
|
+
): Promise<DiscoveredVendor[]> {
|
|
463
|
+
const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
|
|
464
|
+
const anchorHost = options.anchorUrl ? hostOf(options.anchorUrl) : null;
|
|
465
|
+
const anchorNote = anchorHost
|
|
466
|
+
? `The user's own company is ${anchorHost} — list its direct competitors and do NOT include ${anchorHost} itself.`
|
|
467
|
+
: "";
|
|
468
|
+
const prompt = `List the most significant vendors competing in the category "${category}" today.
|
|
469
|
+
${anchorNote}
|
|
470
|
+
Rules:
|
|
471
|
+
- Real companies only, each with its canonical https homepage URL (domain root, no tracking path).
|
|
472
|
+
- 7-9 vendors: a mix of established leaders and notable challengers.
|
|
473
|
+
- EXCLUDE vendors that have been acquired and folded into another brand, or are defunct — i.e. anything whose own site now redirects to a different company. (Name the current independent players instead.)
|
|
474
|
+
- For each, also give productUrl: the page most specific to "${category}". For a big multi-product company, that's its product/solution page for THIS category, not the corporate homepage.
|
|
475
|
+
- No duplicates.`;
|
|
476
|
+
const result = (await forcedToolCall(prompt, "discover_competitors", DISCOVERY_SCHEMA, model, options.llm)) as {
|
|
477
|
+
competitors?: Array<{ name?: string; url?: string; productUrl?: string }>;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const excluded = new Set((options.exclude ?? []).map(hostOf).filter((h): h is string => Boolean(h)));
|
|
481
|
+
if (anchorHost) excluded.add(anchorHost);
|
|
482
|
+
const seenDomain = new Set<string>();
|
|
483
|
+
const out: DiscoveredVendor[] = [];
|
|
484
|
+
for (const c of result?.competitors ?? []) {
|
|
485
|
+
let u: URL;
|
|
486
|
+
try {
|
|
487
|
+
u = new URL(String(c.url));
|
|
488
|
+
} catch {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") continue;
|
|
492
|
+
const host = u.hostname.replace(/^www\./, "");
|
|
493
|
+
if (excluded.has(host)) continue;
|
|
494
|
+
const dom = registrableDomain(host);
|
|
495
|
+
if (seenDomain.has(dom)) continue;
|
|
496
|
+
seenDomain.add(dom);
|
|
497
|
+
let productUrl = u.toString();
|
|
498
|
+
if (c.productUrl) {
|
|
499
|
+
try {
|
|
500
|
+
const p = new URL(String(c.productUrl));
|
|
501
|
+
if (p.protocol === "http:" || p.protocol === "https:") productUrl = p.toString();
|
|
502
|
+
} catch {
|
|
503
|
+
/* keep homepage */
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
out.push({ name: c.name || host, url: u.toString(), productUrl });
|
|
507
|
+
}
|
|
508
|
+
return out;
|
|
509
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -43,11 +43,33 @@ export type PatchOperationType =
|
|
|
43
43
|
| "link_record"
|
|
44
44
|
| "archive_record"
|
|
45
45
|
| "create_task"
|
|
46
|
+
// Create a NET-NEW record (a sourced lead). beforeValue is null (nothing
|
|
47
|
+
// existed); afterValue is a CreateRecordPayload. The connector re-resolves
|
|
48
|
+
// on matchKey at apply time (search is the source of truth; the plan-time
|
|
49
|
+
// snapshot can be stale) and creates only on a confirmed miss, so apply is
|
|
50
|
+
// resolve-first and never double-creates a record a concurrent writer added.
|
|
51
|
+
// Emitted only by `enrich acquire`, and metered against the acquire budget.
|
|
52
|
+
| "create_record"
|
|
46
53
|
// Merge a duplicate group into a survivor. beforeValue is the group's
|
|
47
54
|
// record ids; afterValue is the survivor id (requires_human_survivor_selection
|
|
48
55
|
// until a human picks). IRREVERSIBLE on every provider that supports it.
|
|
49
56
|
| "merge_records";
|
|
50
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The afterValue of a `create_record` operation. The connector re-resolves on
|
|
60
|
+
* `matchKey`/`matchValue` at apply time and creates only on a confirmed miss.
|
|
61
|
+
* `estCostUsd` is the acquire meter's per-record charge, recorded against the
|
|
62
|
+
* budget on a successful create.
|
|
63
|
+
*/
|
|
64
|
+
export type CreateRecordPayload = {
|
|
65
|
+
properties: Record<string, string>;
|
|
66
|
+
matchKey: string;
|
|
67
|
+
matchValue: string;
|
|
68
|
+
source: string;
|
|
69
|
+
estCostUsd?: number;
|
|
70
|
+
associateCompanyName?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
51
73
|
export type AuditFindingSeverity = "info" | "warning" | "critical";
|
|
52
74
|
|
|
53
75
|
/**
|
|
@@ -186,6 +208,8 @@ export type CanonicalContact = {
|
|
|
186
208
|
email?: string;
|
|
187
209
|
phone?: string;
|
|
188
210
|
title?: string;
|
|
211
|
+
/** LinkedIn profile URL — the strongest cross-system identity key for dedup. */
|
|
212
|
+
linkedin?: string;
|
|
189
213
|
ownerId?: string;
|
|
190
214
|
lastActivityAt?: string;
|
|
191
215
|
lastSyncAt?: string;
|