salesprompter-cli 0.1.17 → 0.1.19
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/README.md +1 -0
- package/dist/bigquery.js +9 -4
- package/dist/cli.js +1187 -305
- package/dist/direct-path.js +310 -0
- package/dist/linkedin-products.js +715 -0
- package/dist/sales-navigator.js +475 -0
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -2,20 +2,25 @@
|
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { access } from "node:fs/promises";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
+
import path from "node:path";
|
|
5
6
|
import { emitKeypressEvents } from "node:readline";
|
|
6
7
|
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
9
|
import { Command } from "commander";
|
|
8
10
|
import { z } from "zod";
|
|
9
11
|
import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, requireAuthSession, shouldBypassAuth, verifySession } from "./auth.js";
|
|
10
12
|
import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
|
|
11
13
|
import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
|
|
12
14
|
import { auditDomainDecisions, buildDomainfinderBacklogQueries, buildDomainfinderCandidatesSql, buildDomainfinderInputSql, buildDomainfinderWritebackSql, buildExistingDomainRepairSql, buildExistingDomainAuditQueries, compareDomainSelectionStrategies, selectBestDomains } from "./domainfinder.js";
|
|
15
|
+
import { buildDirectPathLeadExportSql, normalizeDirectPathRows, segmentDirectPathRows } from "./direct-path.js";
|
|
13
16
|
import { AccountLeadProvider, DryRunSyncProvider, HeuristicCompanyProvider, HeuristicEnrichmentProvider, HeuristicPeopleSearchProvider, HeuristicScoringProvider, RoutedSyncProvider } from "./engine.js";
|
|
14
17
|
import { analyzeHistoricalQueries } from "./historical-queries.js";
|
|
15
18
|
import { buildHistoricalVendorIcp, buildVendorIcp } from "./icp-templates.js";
|
|
16
19
|
import { InstantlySyncProvider } from "./instantly.js";
|
|
20
|
+
import { crawlLinkedInProductCategory } from "./linkedin-products.js";
|
|
17
21
|
import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
|
|
18
22
|
import { readJsonFile, splitCsv, writeJsonFile, writeTextFile } from "./io.js";
|
|
23
|
+
import { buildSalesNavigatorCrawlPreview, createSalesNavigatorCrawlSeed, DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS, buildSalesNavigatorPeopleSlice, expandSalesNavigatorCrawlAttempt, SalesNavigatorSliceTooBroadError } from "./sales-navigator.js";
|
|
19
24
|
const require = createRequire(import.meta.url);
|
|
20
25
|
const { version: packageVersion } = require("../package.json");
|
|
21
26
|
const program = new Command();
|
|
@@ -27,6 +32,155 @@ const runtimeOutputOptions = {
|
|
|
27
32
|
json: false,
|
|
28
33
|
quiet: false
|
|
29
34
|
};
|
|
35
|
+
const nullableOptionalString = z.string().min(1).nullish().transform((value) => value ?? undefined);
|
|
36
|
+
const WorkspaceLeadSchema = LeadSchema.extend({
|
|
37
|
+
companySize: nullableOptionalString.optional(),
|
|
38
|
+
country: nullableOptionalString.optional()
|
|
39
|
+
});
|
|
40
|
+
const WorkspaceLeadSearchResponseSchema = z.object({
|
|
41
|
+
leads: z.array(WorkspaceLeadSchema),
|
|
42
|
+
stats: z
|
|
43
|
+
.object({
|
|
44
|
+
count: z.number().int().nonnegative()
|
|
45
|
+
})
|
|
46
|
+
.optional()
|
|
47
|
+
});
|
|
48
|
+
const LinkedInProductIngestResponseSchema = z.object({
|
|
49
|
+
imported: z.number().int().nonnegative(),
|
|
50
|
+
upserted: z.number().int().nonnegative(),
|
|
51
|
+
totalInCatalog: z.number().int().nonnegative().optional()
|
|
52
|
+
});
|
|
53
|
+
const SalesNavigatorExportStartResponseSchema = z.object({
|
|
54
|
+
status: z.literal("accepted"),
|
|
55
|
+
runId: z.string().min(1),
|
|
56
|
+
exportStatus: z.literal("pending"),
|
|
57
|
+
agentId: z.string().min(1),
|
|
58
|
+
containerId: z.string().min(1),
|
|
59
|
+
sourceQueryUrl: z.string().url(),
|
|
60
|
+
slicedQueryUrl: z.string().url(),
|
|
61
|
+
previousContainerId: z.string().min(1).nullable().optional()
|
|
62
|
+
});
|
|
63
|
+
const SalesNavigatorExportRunSchema = z.object({
|
|
64
|
+
id: z.string().min(1),
|
|
65
|
+
status: z.enum(["pending", "finished", "failed"]),
|
|
66
|
+
resultClassification: z.enum([
|
|
67
|
+
"pending",
|
|
68
|
+
"success",
|
|
69
|
+
"too_broad",
|
|
70
|
+
"invalid_session",
|
|
71
|
+
"invalid_result_artifact",
|
|
72
|
+
"transient_failure"
|
|
73
|
+
]),
|
|
74
|
+
errorMessage: z.string().nullable(),
|
|
75
|
+
totalResults: z.number().int().nonnegative().nullable().optional(),
|
|
76
|
+
imported: z.number().int().nonnegative(),
|
|
77
|
+
upserted: z.number().int().nonnegative(),
|
|
78
|
+
resultJsonUrl: z.string().url().nullable().optional(),
|
|
79
|
+
resultCsvUrl: z.string().url().nullable().optional(),
|
|
80
|
+
agentId: z.string().min(1),
|
|
81
|
+
containerId: z.string().min(1),
|
|
82
|
+
sourceQueryUrl: z.string().url(),
|
|
83
|
+
slicedQueryUrl: z.string().url(),
|
|
84
|
+
createdAt: z.string().datetime(),
|
|
85
|
+
updatedAt: z.string().datetime(),
|
|
86
|
+
finishedAt: z.string().datetime().nullable()
|
|
87
|
+
});
|
|
88
|
+
const SalesNavigatorExportRunStatusResponseSchema = z.object({
|
|
89
|
+
status: z.literal("ok"),
|
|
90
|
+
run: SalesNavigatorExportRunSchema
|
|
91
|
+
});
|
|
92
|
+
const SalesNavigatorExportResponseSchema = z.object({
|
|
93
|
+
status: z.literal("ok"),
|
|
94
|
+
runId: z.string().min(1),
|
|
95
|
+
imported: z.number().int().nonnegative(),
|
|
96
|
+
upserted: z.number().int().nonnegative(),
|
|
97
|
+
totalResults: z.number().int().nonnegative().nullable().optional(),
|
|
98
|
+
resultJsonUrl: z.string().url().nullable().optional(),
|
|
99
|
+
resultCsvUrl: z.string().url().nullable().optional(),
|
|
100
|
+
agentId: z.string().min(1),
|
|
101
|
+
containerId: z.string().min(1),
|
|
102
|
+
sourceQueryUrl: z.string().url(),
|
|
103
|
+
slicedQueryUrl: z.string().url()
|
|
104
|
+
});
|
|
105
|
+
const SalesNavigatorCrawlSplitTrailEntrySchema = z.object({
|
|
106
|
+
key: z.string().min(1),
|
|
107
|
+
filterType: z.string().min(1),
|
|
108
|
+
value: z.object({
|
|
109
|
+
id: z.string().min(1).optional(),
|
|
110
|
+
text: z.string().min(1),
|
|
111
|
+
selectionType: z.enum(["INCLUDED", "EXCLUDED"]).optional()
|
|
112
|
+
})
|
|
113
|
+
});
|
|
114
|
+
const SalesNavigatorCrawlJobSummarySchema = z.object({
|
|
115
|
+
id: z.string().uuid(),
|
|
116
|
+
orgId: z.string().min(1),
|
|
117
|
+
sourceQueryUrl: z.string().url(),
|
|
118
|
+
slicePreset: z.string().min(1),
|
|
119
|
+
maxResultsPerSearch: z.number().int().min(1).max(2500),
|
|
120
|
+
numberOfProfiles: z.number().int().min(1).max(2500),
|
|
121
|
+
status: z.enum(["queued", "running", "completed", "completed_with_failures"]),
|
|
122
|
+
queuedSlices: z.number().int().nonnegative(),
|
|
123
|
+
runningSlices: z.number().int().nonnegative(),
|
|
124
|
+
exportedSlices: z.number().int().nonnegative(),
|
|
125
|
+
failedSlices: z.number().int().nonnegative(),
|
|
126
|
+
importedPeople: z.number().int().nonnegative(),
|
|
127
|
+
startedAt: z.string().datetime().nullable(),
|
|
128
|
+
finishedAt: z.string().datetime().nullable(),
|
|
129
|
+
lastError: z.string().nullable(),
|
|
130
|
+
createdAt: z.string().datetime(),
|
|
131
|
+
updatedAt: z.string().datetime(),
|
|
132
|
+
sliceStatusCounts: z.object({
|
|
133
|
+
queued: z.number().int().nonnegative(),
|
|
134
|
+
running: z.number().int().nonnegative(),
|
|
135
|
+
split: z.number().int().nonnegative(),
|
|
136
|
+
exported: z.number().int().nonnegative(),
|
|
137
|
+
retryableFailed: z.number().int().nonnegative(),
|
|
138
|
+
terminalFailed: z.number().int().nonnegative()
|
|
139
|
+
})
|
|
140
|
+
});
|
|
141
|
+
const SalesNavigatorClaimedCrawlSliceSchema = z.object({
|
|
142
|
+
id: z.string().uuid(),
|
|
143
|
+
jobId: z.string().uuid(),
|
|
144
|
+
sourceQueryUrl: z.string().url(),
|
|
145
|
+
slicedQueryUrl: z.string().url(),
|
|
146
|
+
appliedFilters: z.array(z.object({
|
|
147
|
+
type: z.string().min(1),
|
|
148
|
+
values: z.array(z.object({
|
|
149
|
+
id: z.string().min(1).optional(),
|
|
150
|
+
text: z.string().min(1),
|
|
151
|
+
selectionType: z.enum(["INCLUDED", "EXCLUDED"]).optional()
|
|
152
|
+
})).min(1)
|
|
153
|
+
})),
|
|
154
|
+
depth: z.number().int().min(0),
|
|
155
|
+
splitTrail: z.array(SalesNavigatorCrawlSplitTrailEntrySchema),
|
|
156
|
+
retryCount: z.number().int().nonnegative(),
|
|
157
|
+
cookieRetryCount: z.number().int().nonnegative(),
|
|
158
|
+
resultRetryCount: z.number().int().nonnegative(),
|
|
159
|
+
lastError: z.string().nullable(),
|
|
160
|
+
lastErrorCode: z.string().nullable(),
|
|
161
|
+
totalResults: z.number().int().nonnegative().nullable(),
|
|
162
|
+
slicePreset: z.string().min(1),
|
|
163
|
+
maxResultsPerSearch: z.number().int().min(1).max(2500),
|
|
164
|
+
numberOfProfiles: z.number().int().min(1).max(2500)
|
|
165
|
+
});
|
|
166
|
+
const SalesNavigatorCrawlCreateResponseSchema = z.object({
|
|
167
|
+
status: z.literal("ok"),
|
|
168
|
+
resumed: z.boolean(),
|
|
169
|
+
job: SalesNavigatorCrawlJobSummarySchema
|
|
170
|
+
});
|
|
171
|
+
const SalesNavigatorCrawlStatusResponseSchema = z.object({
|
|
172
|
+
status: z.literal("ok"),
|
|
173
|
+
job: SalesNavigatorCrawlJobSummarySchema
|
|
174
|
+
});
|
|
175
|
+
const SalesNavigatorCrawlClaimResponseSchema = z.object({
|
|
176
|
+
status: z.literal("ok"),
|
|
177
|
+
job: SalesNavigatorCrawlJobSummarySchema,
|
|
178
|
+
slice: SalesNavigatorClaimedCrawlSliceSchema.nullable()
|
|
179
|
+
});
|
|
180
|
+
const SalesNavigatorCrawlReportResponseSchema = z.object({
|
|
181
|
+
status: z.literal("ok"),
|
|
182
|
+
job: SalesNavigatorCrawlJobSummarySchema
|
|
183
|
+
});
|
|
30
184
|
function printOutput(value) {
|
|
31
185
|
if (runtimeOutputOptions.quiet) {
|
|
32
186
|
return;
|
|
@@ -68,9 +222,6 @@ function openUrlInBrowser(url) {
|
|
|
68
222
|
}
|
|
69
223
|
}
|
|
70
224
|
function writeDeviceLoginInstructions(info) {
|
|
71
|
-
if (runtimeOutputOptions.json || runtimeOutputOptions.quiet) {
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
225
|
process.stderr.write("Starting device login flow. Complete login in the browser.\n");
|
|
75
226
|
process.stderr.write(`Open this URL: ${info.verificationUrl}\n`);
|
|
76
227
|
process.stderr.write(`Enter this code if prompted: ${info.userCode}\n`);
|
|
@@ -83,9 +234,6 @@ function writeDeviceLoginInstructions(info) {
|
|
|
83
234
|
}
|
|
84
235
|
}
|
|
85
236
|
function writeBrowserLoginInstructions(info) {
|
|
86
|
-
if (runtimeOutputOptions.json || runtimeOutputOptions.quiet) {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
237
|
process.stderr.write("Starting browser login flow. Complete login in the browser.\n");
|
|
90
238
|
process.stderr.write(`Open this URL: ${info.browserUrl}\n`);
|
|
91
239
|
if (openUrlInBrowser(info.browserUrl)) {
|
|
@@ -138,6 +286,22 @@ async function performLogin(options) {
|
|
|
138
286
|
session: result.session
|
|
139
287
|
};
|
|
140
288
|
}
|
|
289
|
+
function canPromptForInteractiveLogin() {
|
|
290
|
+
if (process.env.SALESPROMPTER_FORCE_INTERACTIVE_LOGIN === "1") {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
294
|
+
}
|
|
295
|
+
async function ensureInteractiveAuthSession(apiUrl) {
|
|
296
|
+
if (!canPromptForInteractiveLogin()) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
process.stderr.write("No active Salesprompter session found. Starting login...\n\n");
|
|
300
|
+
await performLogin({
|
|
301
|
+
apiUrl: process.env.SALESPROMPTER_API_BASE_URL?.trim() || apiUrl,
|
|
302
|
+
timeoutSeconds: 180
|
|
303
|
+
});
|
|
304
|
+
}
|
|
141
305
|
function shellQuote(value) {
|
|
142
306
|
if (/^[A-Za-z0-9_./:@=-]+$/.test(value)) {
|
|
143
307
|
return value;
|
|
@@ -174,6 +338,72 @@ function deriveCompanyNameFromDomain(domain) {
|
|
|
174
338
|
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
|
175
339
|
.join(" ");
|
|
176
340
|
}
|
|
341
|
+
function normalizeLinkedInCompanyHandle(value) {
|
|
342
|
+
const trimmed = value.trim();
|
|
343
|
+
if (trimmed.length === 0) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const url = new URL(trimmed);
|
|
348
|
+
if (!/(^|\.)linkedin\.com$/i.test(url.hostname)) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
const segments = url.pathname.split("/").filter((segment) => segment.length > 0);
|
|
352
|
+
const companyIndex = segments.findIndex((segment) => segment.toLowerCase() === "company");
|
|
353
|
+
if (companyIndex === -1) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
const handle = segments[companyIndex + 1]?.trim().toLowerCase() ?? "";
|
|
357
|
+
return handle.length > 0 ? handle : null;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function normalizeLinkedInCompanyPage(handle) {
|
|
364
|
+
return `https://www.linkedin.com/company/${handle}`;
|
|
365
|
+
}
|
|
366
|
+
function titleCaseSlug(value) {
|
|
367
|
+
return value
|
|
368
|
+
.split(/[-_]/)
|
|
369
|
+
.filter((part) => part.length > 0)
|
|
370
|
+
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
|
371
|
+
.join(" ");
|
|
372
|
+
}
|
|
373
|
+
function inferVendorTemplate(reference) {
|
|
374
|
+
if (reference.domain === "deel.com" || reference.linkedinHandle === "deel") {
|
|
375
|
+
return "deel";
|
|
376
|
+
}
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
function parseCompanyReference(value) {
|
|
380
|
+
const rawInput = value.trim();
|
|
381
|
+
if (rawInput.length === 0) {
|
|
382
|
+
throw new Error("Company website or LinkedIn page is required.");
|
|
383
|
+
}
|
|
384
|
+
const linkedinHandle = normalizeLinkedInCompanyHandle(rawInput) ?? undefined;
|
|
385
|
+
const domain = linkedinHandle ? undefined : normalizeDomainInput(rawInput);
|
|
386
|
+
const companyName = domain
|
|
387
|
+
? deriveCompanyNameFromDomain(domain)
|
|
388
|
+
: linkedinHandle
|
|
389
|
+
? titleCaseSlug(linkedinHandle)
|
|
390
|
+
: rawInput;
|
|
391
|
+
const slug = slugify(companyName) || slugify(domain ?? linkedinHandle ?? companyName) || "company";
|
|
392
|
+
const label = domain ?? (linkedinHandle ? normalizeLinkedInCompanyPage(linkedinHandle) : rawInput);
|
|
393
|
+
return {
|
|
394
|
+
rawInput,
|
|
395
|
+
companyName,
|
|
396
|
+
slug,
|
|
397
|
+
label,
|
|
398
|
+
domain: domain && domain.length > 0 ? domain : undefined,
|
|
399
|
+
linkedinCompanyPage: linkedinHandle ? normalizeLinkedInCompanyPage(linkedinHandle) : undefined,
|
|
400
|
+
linkedinHandle,
|
|
401
|
+
vendorTemplate: inferVendorTemplate({
|
|
402
|
+
domain: domain && domain.length > 0 ? domain : undefined,
|
|
403
|
+
linkedinHandle
|
|
404
|
+
})
|
|
405
|
+
};
|
|
406
|
+
}
|
|
177
407
|
function writeWizardLine(message = "") {
|
|
178
408
|
process.stdout.write(`${message}\n`);
|
|
179
409
|
}
|
|
@@ -220,6 +450,9 @@ function buildLeadOutputPaths(baseSlug) {
|
|
|
220
450
|
scoredPath: `./data/${baseSlug}-scored.json`
|
|
221
451
|
};
|
|
222
452
|
}
|
|
453
|
+
function buildQualifiedLeadsPath(baseSlug) {
|
|
454
|
+
return `./data/${baseSlug}-qualified-leads.json`;
|
|
455
|
+
}
|
|
223
456
|
function normalizeChoiceText(value) {
|
|
224
457
|
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
|
|
225
458
|
}
|
|
@@ -273,11 +506,8 @@ async function promptChoiceInteractive(prompt, options, defaultIndex) {
|
|
|
273
506
|
if (!selected) {
|
|
274
507
|
throw new Error("wizard selection invariant violated");
|
|
275
508
|
}
|
|
276
|
-
const summary = `${prompt} ${selected.label}`;
|
|
277
|
-
redrawInteractiveChoice([summary], renderedLineCount);
|
|
278
|
-
renderedLineCount = 1;
|
|
279
|
-
process.stdout.write("\n");
|
|
280
509
|
cleanup();
|
|
510
|
+
process.stdout.write("\n");
|
|
281
511
|
resolve(selected.value);
|
|
282
512
|
};
|
|
283
513
|
const cancel = (reject) => {
|
|
@@ -409,46 +639,6 @@ async function promptYesNo(rl, prompt, defaultValue) {
|
|
|
409
639
|
writeWizardLine("Please answer yes or no.");
|
|
410
640
|
}
|
|
411
641
|
}
|
|
412
|
-
async function maybeSearchLeadDataNow(rl, options) {
|
|
413
|
-
const shouldSearch = await promptYesNo(rl, "Do you want to search your lead data for matches now?", false);
|
|
414
|
-
writeWizardLine();
|
|
415
|
-
if (!shouldSearch) {
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
writeWizardSection("Search your lead data", "I will use the ICP you just saved.");
|
|
419
|
-
await runVendorLookupWizard(rl, { icpPath: options.icpPath });
|
|
420
|
-
}
|
|
421
|
-
async function maybePrepareLeadsForOutreach(rl, options) {
|
|
422
|
-
const shouldScore = await promptYesNo(rl, "Do you want me to score these leads for outreach?", false);
|
|
423
|
-
writeWizardLine();
|
|
424
|
-
if (!shouldScore) {
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
writeWizardSection("Prepare for outreach", "I will enrich and score the leads you just saved.");
|
|
428
|
-
const { enrichedPath, scoredPath } = buildLeadOutputPaths(options.baseSlug);
|
|
429
|
-
const enriched = await enrichmentProvider.enrichLeads(options.leads);
|
|
430
|
-
const scored = await scoringProvider.scoreLeads(options.icp, enriched);
|
|
431
|
-
await writeJsonFile(enrichedPath, enriched);
|
|
432
|
-
await writeJsonFile(scoredPath, scored);
|
|
433
|
-
writeWizardLine(`Saved enriched leads to ${enrichedPath}.`);
|
|
434
|
-
writeWizardLine(`Saved scored leads to ${scoredPath}.`);
|
|
435
|
-
writeWizardLine();
|
|
436
|
-
writeWizardLine("Equivalent raw commands:");
|
|
437
|
-
writeWizardLine(` ${buildCommandLine(["salesprompter", "leads:enrich", "--in", options.leadPath, "--out", enrichedPath])}`);
|
|
438
|
-
writeWizardLine(` ${buildCommandLine(["salesprompter", "leads:score", "--icp", options.icpPath, "--in", enrichedPath, "--out", scoredPath])}`);
|
|
439
|
-
if (!process.env.INSTANTLY_API_KEY || process.env.INSTANTLY_API_KEY.trim().length === 0) {
|
|
440
|
-
writeWizardLine();
|
|
441
|
-
writeWizardLine("You can send the scored leads to Instantly later from the main menu.");
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
writeWizardLine();
|
|
445
|
-
const shouldSync = await promptYesNo(rl, "Do you want to send these leads to Instantly now?", false);
|
|
446
|
-
writeWizardLine();
|
|
447
|
-
if (!shouldSync) {
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
await runOutreachSyncWizard(rl, { inputPath: scoredPath });
|
|
451
|
-
}
|
|
452
642
|
async function ensureWizardSession(options) {
|
|
453
643
|
if (shouldBypassAuth()) {
|
|
454
644
|
return null;
|
|
@@ -465,7 +655,7 @@ async function ensureWizardSession(options) {
|
|
|
465
655
|
throw error;
|
|
466
656
|
}
|
|
467
657
|
}
|
|
468
|
-
writeWizardLine("First, sign in to
|
|
658
|
+
writeWizardLine("First, sign in to continue.");
|
|
469
659
|
writeWizardLine();
|
|
470
660
|
const result = await performLogin({
|
|
471
661
|
apiUrl: options?.apiUrl,
|
|
@@ -475,269 +665,620 @@ async function ensureWizardSession(options) {
|
|
|
475
665
|
writeWizardLine();
|
|
476
666
|
return result.session;
|
|
477
667
|
}
|
|
478
|
-
async function
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
aliases: ["custom", "from scratch", "new profile"]
|
|
668
|
+
async function fetchWorkspaceLeadSearch(session, requestBody) {
|
|
669
|
+
const response = await fetch(`${session.apiBaseUrl}/api/cli/leads/search`, {
|
|
670
|
+
method: "POST",
|
|
671
|
+
headers: {
|
|
672
|
+
"Content-Type": "application/json",
|
|
673
|
+
Authorization: `Bearer ${session.accessToken}`
|
|
485
674
|
},
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
675
|
+
body: JSON.stringify(requestBody)
|
|
676
|
+
});
|
|
677
|
+
const text = await response.text();
|
|
678
|
+
const payload = text.length > 0 ? JSON.parse(text) : {};
|
|
679
|
+
if (!response.ok) {
|
|
680
|
+
const errorMessage = typeof payload === "object" && payload !== null && "error" in payload && typeof payload.error === "string"
|
|
681
|
+
? payload.error
|
|
682
|
+
: `request failed (${response.status})`;
|
|
683
|
+
throw new Error(errorMessage);
|
|
684
|
+
}
|
|
685
|
+
return WorkspaceLeadSearchResponseSchema.parse(payload).leads;
|
|
686
|
+
}
|
|
687
|
+
function buildLinkedInProductsOutputPath(categorySlug) {
|
|
688
|
+
return `./data/linkedin-products-${categorySlug}.json`;
|
|
689
|
+
}
|
|
690
|
+
function collectStringOptionValue(value, previous = []) {
|
|
691
|
+
return [...previous, value];
|
|
692
|
+
}
|
|
693
|
+
class SalesNavigatorExportRequestError extends Error {
|
|
694
|
+
errorCode;
|
|
695
|
+
totalResults;
|
|
696
|
+
runId;
|
|
697
|
+
agentId;
|
|
698
|
+
containerId;
|
|
699
|
+
statusCode;
|
|
700
|
+
constructor(message, options) {
|
|
701
|
+
super(message);
|
|
702
|
+
this.name = "SalesNavigatorExportRequestError";
|
|
703
|
+
this.statusCode = options.statusCode;
|
|
704
|
+
this.errorCode = options.errorCode;
|
|
705
|
+
this.totalResults = options.totalResults ?? null;
|
|
706
|
+
this.runId = options.runId;
|
|
707
|
+
this.agentId = options.agentId;
|
|
708
|
+
this.containerId = options.containerId;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
async function withRefreshableAuthSession(session, run, contextLabel = "Salesprompter session expired during crawl. Refreshing login...") {
|
|
712
|
+
let currentSession = session;
|
|
713
|
+
let authRefreshCount = 0;
|
|
714
|
+
while (true) {
|
|
715
|
+
try {
|
|
716
|
+
return {
|
|
717
|
+
session: currentSession,
|
|
718
|
+
value: await run(currentSession)
|
|
719
|
+
};
|
|
515
720
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
keywords: splitCsv(keywords)
|
|
527
|
-
});
|
|
528
|
-
await writeJsonFile(outPath, icp);
|
|
529
|
-
writeWizardLine(`Created ${icp.name}.`);
|
|
530
|
-
writeWizardLine(`Saved profile to ${outPath}.`);
|
|
531
|
-
writeWizardLine();
|
|
532
|
-
writeWizardLine("Equivalent raw command:");
|
|
533
|
-
const defineArgs = ["salesprompter", "icp:define", "--name", icp.name];
|
|
534
|
-
if (description.trim().length > 0) {
|
|
535
|
-
defineArgs.push("--description", description);
|
|
721
|
+
catch (error) {
|
|
722
|
+
if (!isRefreshableAuthError(error) || !canPromptForInteractiveLogin() || authRefreshCount >= 2) {
|
|
723
|
+
throw error;
|
|
724
|
+
}
|
|
725
|
+
authRefreshCount += 1;
|
|
726
|
+
if (!runtimeOutputOptions.quiet) {
|
|
727
|
+
process.stderr.write(`${contextLabel}\n`);
|
|
728
|
+
}
|
|
729
|
+
await ensureInteractiveAuthSession(currentSession.apiBaseUrl);
|
|
730
|
+
currentSession = await requireAuthSession();
|
|
536
731
|
}
|
|
537
|
-
|
|
538
|
-
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
async function fetchCliJson(session, request, schema) {
|
|
735
|
+
return await withRefreshableAuthSession(session, async (currentSession) => {
|
|
736
|
+
const response = await request(currentSession);
|
|
737
|
+
const text = await response.text();
|
|
738
|
+
const parsed = text.length > 0 ? JSON.parse(text) : {};
|
|
739
|
+
if (!response.ok) {
|
|
740
|
+
const errorMessage = typeof parsed === "object" &&
|
|
741
|
+
parsed !== null &&
|
|
742
|
+
"error" in parsed &&
|
|
743
|
+
typeof parsed.error === "string"
|
|
744
|
+
? parsed.error
|
|
745
|
+
: `request failed (${response.status})`;
|
|
746
|
+
throw new Error(errorMessage);
|
|
539
747
|
}
|
|
540
|
-
|
|
541
|
-
|
|
748
|
+
return schema.parse(parsed);
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
async function uploadLinkedInProductsCatalog(session, payload, batchSize = 100) {
|
|
752
|
+
let imported = 0;
|
|
753
|
+
let upserted = 0;
|
|
754
|
+
for (let startIndex = 0; startIndex < payload.items.length; startIndex += batchSize) {
|
|
755
|
+
const batch = payload.items.slice(startIndex, startIndex + batchSize);
|
|
756
|
+
const response = await fetch(`${session.apiBaseUrl}/api/cli/linkedin-products/ingest`, {
|
|
757
|
+
method: "POST",
|
|
758
|
+
headers: {
|
|
759
|
+
"Content-Type": "application/json",
|
|
760
|
+
Authorization: `Bearer ${session.accessToken}`
|
|
761
|
+
},
|
|
762
|
+
body: JSON.stringify({
|
|
763
|
+
source: payload.source,
|
|
764
|
+
items: batch
|
|
765
|
+
})
|
|
766
|
+
});
|
|
767
|
+
const text = await response.text();
|
|
768
|
+
const parsed = text.length > 0 ? JSON.parse(text) : {};
|
|
769
|
+
if (!response.ok) {
|
|
770
|
+
const errorMessage = typeof parsed === "object" && parsed !== null && "error" in parsed && typeof parsed.error === "string"
|
|
771
|
+
? parsed.error
|
|
772
|
+
: `request failed (${response.status})`;
|
|
773
|
+
throw new Error(errorMessage);
|
|
542
774
|
}
|
|
543
|
-
|
|
544
|
-
|
|
775
|
+
const result = LinkedInProductIngestResponseSchema.parse(parsed);
|
|
776
|
+
imported += result.imported;
|
|
777
|
+
upserted += result.upserted;
|
|
778
|
+
}
|
|
779
|
+
return { imported, upserted };
|
|
780
|
+
}
|
|
781
|
+
function serializeSalesNavigatorFiltersForApi(filters) {
|
|
782
|
+
return filters.map((filter) => ({
|
|
783
|
+
type: filter.type,
|
|
784
|
+
values: filter.values.map((value) => ({
|
|
785
|
+
id: value.id ?? value.text,
|
|
786
|
+
text: value.text,
|
|
787
|
+
selectionType: value.selectionType
|
|
788
|
+
}))
|
|
789
|
+
}));
|
|
790
|
+
}
|
|
791
|
+
async function runSalesNavigatorExport(session, payload) {
|
|
792
|
+
const started = await startSalesNavigatorExport(session, payload);
|
|
793
|
+
const completed = await waitForSalesNavigatorExportRunCompletion(started.session, started.value.runId);
|
|
794
|
+
return mapCompletedSalesNavigatorExportRun(completed.value.run);
|
|
795
|
+
}
|
|
796
|
+
async function startSalesNavigatorExport(session, payload) {
|
|
797
|
+
return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/export`, {
|
|
798
|
+
method: "POST",
|
|
799
|
+
headers: {
|
|
800
|
+
"Content-Type": "application/json",
|
|
801
|
+
Authorization: `Bearer ${currentSession.accessToken}`
|
|
802
|
+
},
|
|
803
|
+
body: JSON.stringify({
|
|
804
|
+
...payload,
|
|
805
|
+
appliedFilters: serializeSalesNavigatorFiltersForApi(payload.appliedFilters)
|
|
806
|
+
})
|
|
807
|
+
}), SalesNavigatorExportStartResponseSchema);
|
|
808
|
+
}
|
|
809
|
+
async function getSalesNavigatorExportRunStatus(session, runId) {
|
|
810
|
+
return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/export-runs/${runId}?refresh=1`, {
|
|
811
|
+
method: "GET",
|
|
812
|
+
headers: {
|
|
813
|
+
Authorization: `Bearer ${currentSession.accessToken}`
|
|
545
814
|
}
|
|
546
|
-
|
|
547
|
-
|
|
815
|
+
}), SalesNavigatorExportRunStatusResponseSchema);
|
|
816
|
+
}
|
|
817
|
+
function mapCompletedSalesNavigatorExportRun(run) {
|
|
818
|
+
if (run.status === "pending") {
|
|
819
|
+
throw new Error(`Sales Navigator export run ${run.id} is still pending`);
|
|
820
|
+
}
|
|
821
|
+
if (run.resultClassification === "too_broad") {
|
|
822
|
+
throw new SalesNavigatorSliceTooBroadError(run.errorMessage ?? "Sliced Sales Navigator query is still too broad.", {
|
|
823
|
+
totalResults: run.totalResults ?? null,
|
|
824
|
+
details: run
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
if (run.resultClassification !== "success") {
|
|
828
|
+
throw new SalesNavigatorExportRequestError(run.errorMessage ?? "Sales Navigator export failed", {
|
|
829
|
+
statusCode: 502,
|
|
830
|
+
errorCode: run.resultClassification,
|
|
831
|
+
totalResults: run.totalResults ?? null,
|
|
832
|
+
runId: run.id,
|
|
833
|
+
agentId: run.agentId,
|
|
834
|
+
containerId: run.containerId
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
return SalesNavigatorExportResponseSchema.parse({
|
|
838
|
+
status: "ok",
|
|
839
|
+
runId: run.id,
|
|
840
|
+
imported: run.imported,
|
|
841
|
+
upserted: run.upserted,
|
|
842
|
+
totalResults: run.totalResults ?? null,
|
|
843
|
+
resultJsonUrl: run.resultJsonUrl ?? null,
|
|
844
|
+
resultCsvUrl: run.resultCsvUrl ?? null,
|
|
845
|
+
agentId: run.agentId,
|
|
846
|
+
containerId: run.containerId,
|
|
847
|
+
sourceQueryUrl: run.sourceQueryUrl,
|
|
848
|
+
slicedQueryUrl: run.slicedQueryUrl
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
async function waitForSalesNavigatorExportRunCompletion(session, runId, options = {}) {
|
|
852
|
+
const timeoutSeconds = options.timeoutSeconds ?? 960;
|
|
853
|
+
const pollIntervalMs = options.pollIntervalMs ?? 5000;
|
|
854
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
855
|
+
let currentSession = session;
|
|
856
|
+
while (Date.now() < deadline) {
|
|
857
|
+
const status = await getSalesNavigatorExportRunStatus(currentSession, runId);
|
|
858
|
+
currentSession = status.session;
|
|
859
|
+
if (status.value.run.status !== "pending") {
|
|
860
|
+
return status;
|
|
548
861
|
}
|
|
549
|
-
|
|
550
|
-
|
|
862
|
+
await delay(pollIntervalMs);
|
|
863
|
+
}
|
|
864
|
+
throw new SalesNavigatorExportRequestError(`Timed out waiting for Sales Navigator export run ${runId}`, {
|
|
865
|
+
statusCode: 504,
|
|
866
|
+
runId
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
function isSalesNavigatorAgentBusyError(error) {
|
|
870
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
871
|
+
return /parallel executions limit/i.test(message);
|
|
872
|
+
}
|
|
873
|
+
function isSalesNavigatorSessionError(error) {
|
|
874
|
+
if (error instanceof SalesNavigatorExportRequestError &&
|
|
875
|
+
["phantombuster_cant_connect_profile", "salesnav_upsell_detected", "linkedin_session_invalid"].includes(error.errorCode ?? "")) {
|
|
876
|
+
return true;
|
|
877
|
+
}
|
|
878
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
879
|
+
return /can't connect profile|sales navigator account|upsell|linkedin session invalid/i.test(message);
|
|
880
|
+
}
|
|
881
|
+
function isSalesNavigatorResultArtifactError(error) {
|
|
882
|
+
if (error instanceof SalesNavigatorExportRequestError && error.errorCode === "phantombuster_result_invalid") {
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
886
|
+
return /page has crashed|no valid sales navigator people rows/i.test(message);
|
|
887
|
+
}
|
|
888
|
+
function isSalesNavigatorTransientExportError(error) {
|
|
889
|
+
if (isSalesNavigatorSessionError(error) || isSalesNavigatorResultArtifactError(error)) {
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
if (error instanceof SalesNavigatorExportRequestError) {
|
|
893
|
+
return error.statusCode >= 500;
|
|
894
|
+
}
|
|
895
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
896
|
+
return /enotfound|fetch failed|network|timed out|socket hang up/i.test(message);
|
|
897
|
+
}
|
|
898
|
+
function isRefreshableAuthError(error) {
|
|
899
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
900
|
+
return /token expired|session expired|not logged in|missing bearer token/i.test(message);
|
|
901
|
+
}
|
|
902
|
+
async function runSalesNavigatorExportWithAgentWait(session, payload, options) {
|
|
903
|
+
let busyWaitCount = 0;
|
|
904
|
+
let currentSession = session;
|
|
905
|
+
let authRefreshCount = 0;
|
|
906
|
+
while (true) {
|
|
907
|
+
try {
|
|
908
|
+
return await runSalesNavigatorExport(currentSession, payload);
|
|
551
909
|
}
|
|
552
|
-
|
|
553
|
-
|
|
910
|
+
catch (error) {
|
|
911
|
+
if (isRefreshableAuthError(error)) {
|
|
912
|
+
if (!canPromptForInteractiveLogin() || authRefreshCount >= 2) {
|
|
913
|
+
throw error;
|
|
914
|
+
}
|
|
915
|
+
authRefreshCount += 1;
|
|
916
|
+
if (!runtimeOutputOptions.quiet) {
|
|
917
|
+
process.stderr.write("Salesprompter session expired during crawl. Refreshing login...\n");
|
|
918
|
+
}
|
|
919
|
+
await ensureInteractiveAuthSession(currentSession.apiBaseUrl);
|
|
920
|
+
currentSession = await requireAuthSession();
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (isSalesNavigatorAgentBusyError(error)) {
|
|
924
|
+
if (busyWaitCount === options.maxWaits) {
|
|
925
|
+
throw error;
|
|
926
|
+
}
|
|
927
|
+
busyWaitCount += 1;
|
|
928
|
+
if (!runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
|
|
929
|
+
process.stderr.write(`Sales Navigator export agent is busy. Waiting ${options.waitSeconds}s before retrying...\n`);
|
|
930
|
+
}
|
|
931
|
+
await delay(options.waitSeconds * 1000);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
throw error;
|
|
554
935
|
}
|
|
555
|
-
defineArgs.push("--out", outPath);
|
|
556
|
-
writeWizardLine(` ${buildCommandLine(defineArgs)}`);
|
|
557
|
-
writeWizardLine();
|
|
558
|
-
await maybeSearchLeadDataNow(rl, { icpPath: outPath });
|
|
559
|
-
return;
|
|
560
936
|
}
|
|
561
|
-
const vendor = "deel";
|
|
562
|
-
writeWizardSection("Define your ICP", "Use the built-in Deel template and choose a market.");
|
|
563
|
-
writeWizardLine("Using the built-in Deel ICP template.");
|
|
564
|
-
writeWizardLine();
|
|
565
|
-
const market = await promptChoice(rl, "Which market do you want to focus on?", [
|
|
566
|
-
{ value: "dach", label: "DACH", description: "Germany, Austria, Switzerland" },
|
|
567
|
-
{ value: "europe", label: "Europe" },
|
|
568
|
-
{ value: "global", label: "Global" }
|
|
569
|
-
], "dach");
|
|
570
|
-
writeWizardLine();
|
|
571
|
-
const outPath = `./data/${slugify(vendor)}-icp-${market}.json`;
|
|
572
|
-
const icp = buildVendorIcp(vendor, market);
|
|
573
|
-
await writeJsonFile(outPath, icp);
|
|
574
|
-
writeWizardLine(`Created ${icp.name}.`);
|
|
575
|
-
writeWizardLine(`Saved profile to ${outPath}.`);
|
|
576
|
-
writeWizardLine();
|
|
577
|
-
writeWizardLine("Equivalent raw command:");
|
|
578
|
-
writeWizardLine(` ${buildCommandLine(["salesprompter", "icp:vendor", "--vendor", vendor, "--market", market, "--out", outPath])}`);
|
|
579
|
-
writeWizardLine();
|
|
580
|
-
await maybeSearchLeadDataNow(rl, { icpPath: outPath });
|
|
581
937
|
}
|
|
582
|
-
async function
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
const slug = slugify(domain);
|
|
601
|
-
const icpPath = `./data/${slug}-target-icp.json`;
|
|
602
|
-
const { leadsPath } = buildLeadOutputPaths(slug);
|
|
603
|
-
const icp = IcpSchema.parse({
|
|
604
|
-
name: `${displayName} target account`,
|
|
605
|
-
regions: region.length > 0 ? [region] : [],
|
|
606
|
-
industries: splitCsv(industries),
|
|
607
|
-
titles: splitCsv(titles)
|
|
938
|
+
async function runSalesNavigatorCrawlAttempt(session, attempt, options, context) {
|
|
939
|
+
const shouldProbe = options.probeProfiles > 0 &&
|
|
940
|
+
options.probeProfiles < attempt.numberOfProfiles &&
|
|
941
|
+
attempt.depth < options.maxSplitDepth;
|
|
942
|
+
const probeProfiles = shouldProbe ? Math.max(1, options.probeProfiles) : attempt.numberOfProfiles;
|
|
943
|
+
const probeResult = await runSalesNavigatorExportWithAgentWait(session, {
|
|
944
|
+
sourceQueryUrl: attempt.sourceQueryUrl,
|
|
945
|
+
slicedQueryUrl: attempt.slicedQueryUrl,
|
|
946
|
+
appliedFilters: attempt.appliedFilters,
|
|
947
|
+
maxResultsPerSearch: attempt.maxResultsPerSearch,
|
|
948
|
+
numberOfProfiles: probeProfiles,
|
|
949
|
+
slicePreset: attempt.slicePreset,
|
|
950
|
+
crawlJobId: context?.crawlJobId,
|
|
951
|
+
crawlSliceId: context?.crawlSliceId
|
|
952
|
+
}, {
|
|
953
|
+
waitSeconds: options.agentBusyWaitSeconds,
|
|
954
|
+
maxWaits: options.agentBusyMaxWaits
|
|
608
955
|
});
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
defineArgs.push("--industries", industries);
|
|
629
|
-
}
|
|
630
|
-
if (titles.trim().length > 0) {
|
|
631
|
-
defineArgs.push("--titles", titles);
|
|
632
|
-
}
|
|
633
|
-
defineArgs.push("--out", icpPath);
|
|
634
|
-
writeWizardLine(` ${buildCommandLine(defineArgs)}`);
|
|
635
|
-
const leadArgs = ["salesprompter", "leads:generate", "--icp", icpPath, "--count", String(leadCount), "--domain", domain];
|
|
636
|
-
leadArgs.push("--out", leadsPath);
|
|
637
|
-
writeWizardLine(` ${buildCommandLine(leadArgs)}`);
|
|
638
|
-
writeWizardLine();
|
|
639
|
-
await maybePrepareLeadsForOutreach(rl, {
|
|
640
|
-
baseSlug: slug,
|
|
641
|
-
icp,
|
|
642
|
-
icpPath,
|
|
643
|
-
leadPath: leadsPath,
|
|
644
|
-
leads: result.leads
|
|
956
|
+
if (!shouldProbe) {
|
|
957
|
+
return probeResult;
|
|
958
|
+
}
|
|
959
|
+
const totalResults = probeResult.totalResults ?? null;
|
|
960
|
+
if (totalResults === null || totalResults > attempt.maxResultsPerSearch) {
|
|
961
|
+
return probeResult;
|
|
962
|
+
}
|
|
963
|
+
return await runSalesNavigatorExportWithAgentWait(session, {
|
|
964
|
+
sourceQueryUrl: attempt.sourceQueryUrl,
|
|
965
|
+
slicedQueryUrl: attempt.slicedQueryUrl,
|
|
966
|
+
appliedFilters: attempt.appliedFilters,
|
|
967
|
+
maxResultsPerSearch: attempt.maxResultsPerSearch,
|
|
968
|
+
numberOfProfiles: attempt.numberOfProfiles,
|
|
969
|
+
slicePreset: attempt.slicePreset,
|
|
970
|
+
crawlJobId: context?.crawlJobId,
|
|
971
|
+
crawlSliceId: context?.crawlSliceId
|
|
972
|
+
}, {
|
|
973
|
+
waitSeconds: options.agentBusyWaitSeconds,
|
|
974
|
+
maxWaits: options.agentBusyMaxWaits
|
|
645
975
|
});
|
|
646
976
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
977
|
+
function buildSalesNavigatorCrawlAttemptFromClaimedSlice(slice) {
|
|
978
|
+
return {
|
|
979
|
+
sourceQueryUrl: slice.sourceQueryUrl,
|
|
980
|
+
slicedQueryUrl: slice.slicedQueryUrl,
|
|
981
|
+
appliedFilters: slice.appliedFilters,
|
|
982
|
+
depth: slice.depth,
|
|
983
|
+
retryCount: slice.retryCount,
|
|
984
|
+
maxResultsPerSearch: slice.maxResultsPerSearch,
|
|
985
|
+
numberOfProfiles: slice.numberOfProfiles,
|
|
986
|
+
slicePreset: slice.slicePreset,
|
|
987
|
+
splitTrail: slice.splitTrail
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
async function createOrResumeSalesNavigatorCrawlJob(session, payload) {
|
|
991
|
+
return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/crawls`, {
|
|
992
|
+
method: "POST",
|
|
993
|
+
headers: {
|
|
994
|
+
"Content-Type": "application/json",
|
|
995
|
+
Authorization: `Bearer ${currentSession.accessToken}`
|
|
654
996
|
},
|
|
655
|
-
{
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
997
|
+
body: JSON.stringify({
|
|
998
|
+
...payload,
|
|
999
|
+
rootSlice: {
|
|
1000
|
+
...payload.rootSlice,
|
|
1001
|
+
appliedFilters: serializeSalesNavigatorFiltersForApi(payload.rootSlice.appliedFilters)
|
|
1002
|
+
}
|
|
1003
|
+
})
|
|
1004
|
+
}), SalesNavigatorCrawlCreateResponseSchema);
|
|
1005
|
+
}
|
|
1006
|
+
async function getSalesNavigatorCrawlStatus(session, jobId) {
|
|
1007
|
+
return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/crawls/${jobId}`, {
|
|
1008
|
+
method: "GET",
|
|
1009
|
+
headers: {
|
|
1010
|
+
Authorization: `Bearer ${currentSession.accessToken}`
|
|
660
1011
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
1012
|
+
}), SalesNavigatorCrawlStatusResponseSchema);
|
|
1013
|
+
}
|
|
1014
|
+
async function claimNextSalesNavigatorCrawlSlice(session, jobId) {
|
|
1015
|
+
return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/crawls/${jobId}/claim-next`, {
|
|
1016
|
+
method: "POST",
|
|
1017
|
+
headers: {
|
|
1018
|
+
Authorization: `Bearer ${currentSession.accessToken}`
|
|
1019
|
+
}
|
|
1020
|
+
}), SalesNavigatorCrawlClaimResponseSchema);
|
|
1021
|
+
}
|
|
1022
|
+
async function reportSalesNavigatorCrawlSlice(session, jobId, payload) {
|
|
1023
|
+
return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/crawls/${jobId}/report`, {
|
|
1024
|
+
method: "POST",
|
|
1025
|
+
headers: {
|
|
1026
|
+
"Content-Type": "application/json",
|
|
1027
|
+
Authorization: `Bearer ${currentSession.accessToken}`
|
|
1028
|
+
},
|
|
1029
|
+
body: JSON.stringify({
|
|
1030
|
+
...payload,
|
|
1031
|
+
children: payload.children?.map((child) => ({
|
|
1032
|
+
...child,
|
|
1033
|
+
appliedFilters: serializeSalesNavigatorFiltersForApi(child.appliedFilters)
|
|
1034
|
+
}))
|
|
1035
|
+
})
|
|
1036
|
+
}), SalesNavigatorCrawlReportResponseSchema);
|
|
1037
|
+
}
|
|
1038
|
+
function nextSalesNavigatorSplitDimension(slice, maxSplitDepth) {
|
|
1039
|
+
if (slice.depth >= maxSplitDepth) {
|
|
1040
|
+
return null;
|
|
666
1041
|
}
|
|
667
|
-
|
|
1042
|
+
return DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS[slice.depth] ?? null;
|
|
1043
|
+
}
|
|
1044
|
+
const SALES_NAVIGATOR_COOKIE_RETRY_LIMIT = 8;
|
|
1045
|
+
const SALES_NAVIGATOR_RESULT_RETRY_LIMIT = 3;
|
|
1046
|
+
function buildSalesNavigatorSplitChildren(slice, dimension) {
|
|
1047
|
+
const attempt = buildSalesNavigatorCrawlAttemptFromClaimedSlice(slice);
|
|
1048
|
+
return expandSalesNavigatorCrawlAttempt(attempt, dimension).map((child) => ({
|
|
1049
|
+
slicedQueryUrl: child.slicedQueryUrl,
|
|
1050
|
+
appliedFilters: child.appliedFilters,
|
|
1051
|
+
depth: child.depth,
|
|
1052
|
+
splitTrail: child.splitTrail
|
|
1053
|
+
}));
|
|
668
1054
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
1055
|
+
function buildSalesNavigatorSliceFailureReport(slice, error, options) {
|
|
1056
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1057
|
+
const exportError = error instanceof SalesNavigatorExportRequestError ? error : null;
|
|
1058
|
+
if (error instanceof SalesNavigatorSliceTooBroadError) {
|
|
1059
|
+
const nextDimension = nextSalesNavigatorSplitDimension(slice, options.maxSplitDepth);
|
|
1060
|
+
if (nextDimension) {
|
|
1061
|
+
return {
|
|
1062
|
+
sliceId: slice.id,
|
|
1063
|
+
outcome: "split",
|
|
1064
|
+
totalResults: error.totalResults,
|
|
1065
|
+
error: message,
|
|
1066
|
+
errorCode: "too_broad",
|
|
1067
|
+
children: buildSalesNavigatorSplitChildren(slice, nextDimension)
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
return {
|
|
1071
|
+
sliceId: slice.id,
|
|
1072
|
+
outcome: "terminal_failed",
|
|
1073
|
+
totalResults: error.totalResults,
|
|
1074
|
+
error: message,
|
|
1075
|
+
errorCode: "too_broad_no_remaining_dimensions"
|
|
1076
|
+
};
|
|
672
1077
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1078
|
+
if (isSalesNavigatorSessionError(error)) {
|
|
1079
|
+
return {
|
|
1080
|
+
sliceId: slice.id,
|
|
1081
|
+
outcome: slice.cookieRetryCount + 1 <= SALES_NAVIGATOR_COOKIE_RETRY_LIMIT
|
|
1082
|
+
? "retryable_failed"
|
|
1083
|
+
: "terminal_failed",
|
|
1084
|
+
totalResults: exportError?.totalResults,
|
|
1085
|
+
error: message,
|
|
1086
|
+
errorCode: exportError?.errorCode ?? "invalid_session",
|
|
1087
|
+
exportRunId: exportError?.runId,
|
|
1088
|
+
incrementCookieRetryCount: 1
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
if (isSalesNavigatorResultArtifactError(error)) {
|
|
1092
|
+
return {
|
|
1093
|
+
sliceId: slice.id,
|
|
1094
|
+
outcome: slice.resultRetryCount + 1 <= SALES_NAVIGATOR_RESULT_RETRY_LIMIT
|
|
1095
|
+
? "retryable_failed"
|
|
1096
|
+
: "terminal_failed",
|
|
1097
|
+
totalResults: exportError?.totalResults,
|
|
1098
|
+
error: message,
|
|
1099
|
+
errorCode: exportError?.errorCode ?? "invalid_result_artifact",
|
|
1100
|
+
exportRunId: exportError?.runId,
|
|
1101
|
+
incrementResultRetryCount: 1
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
return {
|
|
1105
|
+
sliceId: slice.id,
|
|
1106
|
+
outcome: slice.retryCount + 1 <= options.maxRetries ? "retryable_failed" : "terminal_failed",
|
|
1107
|
+
totalResults: exportError?.totalResults,
|
|
1108
|
+
error: message,
|
|
1109
|
+
errorCode: exportError?.errorCode ?? (isSalesNavigatorTransientExportError(error) ? "transient_failure" : "runtime_error"),
|
|
1110
|
+
exportRunId: exportError?.runId,
|
|
1111
|
+
incrementRetryCount: 1
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
function formatSalesNavigatorSplitTrail(splitTrail) {
|
|
1115
|
+
return splitTrail.map((entry) => `${entry.key}:${entry.value.text}`);
|
|
1116
|
+
}
|
|
1117
|
+
async function executeSalesNavigatorCrawlJob(session, jobId, options) {
|
|
1118
|
+
let currentSession = session;
|
|
1119
|
+
let claimedSlices = 0;
|
|
1120
|
+
const seenSliceIds = new Set();
|
|
1121
|
+
let activeSlice = null;
|
|
1122
|
+
let job = null;
|
|
1123
|
+
let lastOutcome = null;
|
|
1124
|
+
while (true) {
|
|
1125
|
+
if (claimedSlices >= options.maxSlices && lastOutcome?.outcome !== "retryable_failed") {
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
const claimed = await claimNextSalesNavigatorCrawlSlice(currentSession, jobId);
|
|
1129
|
+
currentSession = claimed.session;
|
|
1130
|
+
job = claimed.value.job;
|
|
1131
|
+
if (!claimed.value.slice) {
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
const slice = claimed.value.slice;
|
|
1135
|
+
activeSlice = slice;
|
|
1136
|
+
const isNewSlice = !seenSliceIds.has(slice.id);
|
|
1137
|
+
if (isNewSlice) {
|
|
1138
|
+
seenSliceIds.add(slice.id);
|
|
1139
|
+
claimedSlices += 1;
|
|
1140
|
+
}
|
|
1141
|
+
if (!runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
|
|
1142
|
+
process.stderr.write(`Processing Sales Navigator slice ${claimedSlices}/${options.maxSlices}: ${slice.slicedQueryUrl}\n`);
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
const result = await runSalesNavigatorCrawlAttempt(currentSession, buildSalesNavigatorCrawlAttemptFromClaimedSlice(slice), {
|
|
1146
|
+
maxSplitDepth: options.maxSplitDepth,
|
|
1147
|
+
probeProfiles: options.probeProfiles,
|
|
1148
|
+
agentBusyWaitSeconds: options.agentBusyWaitSeconds,
|
|
1149
|
+
agentBusyMaxWaits: options.agentBusyMaxWaits
|
|
1150
|
+
}, {
|
|
1151
|
+
crawlJobId: jobId,
|
|
1152
|
+
crawlSliceId: slice.id
|
|
1153
|
+
});
|
|
1154
|
+
const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, {
|
|
1155
|
+
sliceId: slice.id,
|
|
1156
|
+
outcome: "exported",
|
|
1157
|
+
totalResults: result.totalResults ?? null,
|
|
1158
|
+
exportRunId: result.runId,
|
|
1159
|
+
importedPeople: result.imported,
|
|
1160
|
+
upsertedPeople: result.upserted
|
|
1161
|
+
});
|
|
1162
|
+
currentSession = reported.session;
|
|
1163
|
+
job = reported.value.job;
|
|
1164
|
+
lastOutcome = {
|
|
1165
|
+
outcome: "exported",
|
|
1166
|
+
runId: result.runId,
|
|
1167
|
+
totalResults: result.totalResults ?? null
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
catch (error) {
|
|
1171
|
+
const payload = buildSalesNavigatorSliceFailureReport(slice, error, {
|
|
1172
|
+
maxSplitDepth: options.maxSplitDepth,
|
|
1173
|
+
maxRetries: options.maxRetries
|
|
1174
|
+
});
|
|
1175
|
+
const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, payload);
|
|
1176
|
+
currentSession = reported.session;
|
|
1177
|
+
job = reported.value.job;
|
|
1178
|
+
lastOutcome = {
|
|
1179
|
+
outcome: payload.outcome,
|
|
1180
|
+
runId: payload.exportRunId,
|
|
1181
|
+
error: payload.error,
|
|
1182
|
+
errorCode: payload.errorCode,
|
|
1183
|
+
totalResults: payload.totalResults
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (!job) {
|
|
1188
|
+
const status = await getSalesNavigatorCrawlStatus(currentSession, jobId);
|
|
1189
|
+
currentSession = status.session;
|
|
1190
|
+
job = status.value.job;
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
session: currentSession,
|
|
1194
|
+
job,
|
|
1195
|
+
claimedSlices,
|
|
1196
|
+
truncated: claimedSlices >= options.maxSlices && (job.queuedSlices > 0 || job.runningSlices > 0),
|
|
1197
|
+
activeSlice,
|
|
1198
|
+
lastOutcome
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
async function searchReferenceCompanyLeads(reference, icp, limit) {
|
|
1202
|
+
if (shouldBypassAuth()) {
|
|
1203
|
+
const fallbackTargetDomain = reference.domain ?? `${reference.slug}.com`;
|
|
1204
|
+
const result = await leadProvider.generateLeads(icp, limit, {
|
|
1205
|
+
companyDomain: fallbackTargetDomain,
|
|
1206
|
+
companyName: reference.companyName
|
|
678
1207
|
});
|
|
679
|
-
|
|
680
|
-
writeWizardLine(`Using profile from ${icpPath}.`);
|
|
1208
|
+
return result.leads;
|
|
681
1209
|
}
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const sqlPath = `./data/${slug}-lookup.sql`;
|
|
688
|
-
const rawPath = `./data/${slug}-leads-raw.json`;
|
|
689
|
-
const { leadsPath: leadPath } = buildLeadOutputPaths(slug);
|
|
690
|
-
const sql = buildBigQueryLeadLookupSql(icp, {
|
|
691
|
-
table: "icpidentifier.SalesGPT.leadPool_new",
|
|
692
|
-
companyField: "companyName",
|
|
693
|
-
domainField: "domain",
|
|
694
|
-
regionField: undefined,
|
|
695
|
-
keywordFields: splitCsv("companyName,industry,description,tagline,specialties"),
|
|
696
|
-
titleField: "jobTitle",
|
|
697
|
-
industryField: "industry",
|
|
698
|
-
companySizeField: "companySize",
|
|
699
|
-
countryField: "company_countryCode",
|
|
700
|
-
firstNameField: "firstName",
|
|
701
|
-
lastNameField: "lastName",
|
|
702
|
-
emailField: "email",
|
|
703
|
-
limit,
|
|
704
|
-
additionalWhere: undefined,
|
|
705
|
-
useSalesprompterGuards: true
|
|
1210
|
+
const session = await requireAuthSession();
|
|
1211
|
+
return await fetchWorkspaceLeadSearch(session, {
|
|
1212
|
+
mode: "reference-company",
|
|
1213
|
+
icp,
|
|
1214
|
+
limit
|
|
706
1215
|
});
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
executedRowCount = parsedRows.length;
|
|
717
|
-
}
|
|
718
|
-
writeWizardLine(`Saved search SQL to ${sqlPath}.`);
|
|
719
|
-
if (execute) {
|
|
720
|
-
writeWizardLine(`Saved ${executedRowCount ?? 0} raw rows to ${rawPath}.`);
|
|
721
|
-
writeWizardLine(`Saved leads to ${leadPath}.`);
|
|
1216
|
+
}
|
|
1217
|
+
async function searchTargetCompanyLeads(reference, limit) {
|
|
1218
|
+
if (shouldBypassAuth()) {
|
|
1219
|
+
const fallbackTargetDomain = reference.domain ?? `${reference.slug}.com`;
|
|
1220
|
+
const result = await leadProvider.generateLeads(IcpSchema.parse({ name: `${reference.companyName} qualified leads` }), limit, {
|
|
1221
|
+
companyDomain: fallbackTargetDomain,
|
|
1222
|
+
companyName: reference.companyName
|
|
1223
|
+
});
|
|
1224
|
+
return result.leads;
|
|
722
1225
|
}
|
|
1226
|
+
const session = await requireAuthSession();
|
|
1227
|
+
return await fetchWorkspaceLeadSearch(session, {
|
|
1228
|
+
mode: "target-company",
|
|
1229
|
+
domain: reference.domain,
|
|
1230
|
+
linkedinCompanyPage: reference.linkedinCompanyPage,
|
|
1231
|
+
limit
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
async function runReferenceCompanyWizard(rl) {
|
|
1235
|
+
writeWizardSection("Reference company", "Paste the website or LinkedIn company page for the company you sell for.");
|
|
1236
|
+
const reference = parseCompanyReference(await promptText(rl, "Which company are you selling for?", {
|
|
1237
|
+
required: true
|
|
1238
|
+
}));
|
|
723
1239
|
writeWizardLine();
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
if (execute) {
|
|
727
|
-
lookupArgs.push("--execute", "--out", rawPath, "--lead-out", leadPath);
|
|
1240
|
+
if (reference.vendorTemplate !== "deel") {
|
|
1241
|
+
throw new Error("Automatic company-to-ICP matching is available for Deel right now. Try deel.com or the Deel LinkedIn company page.");
|
|
728
1242
|
}
|
|
729
|
-
|
|
730
|
-
|
|
1243
|
+
writeWizardSection("Find matching leads", `Using the built-in ${reference.companyName} profile to search your workspace data.`);
|
|
1244
|
+
const market = await promptChoice(rl, "Where do you want to search?", [
|
|
1245
|
+
{ value: "dach", label: "DACH", description: "Germany, Austria, Switzerland" },
|
|
1246
|
+
{ value: "europe", label: "Europe" },
|
|
1247
|
+
{ value: "global", label: "Global" }
|
|
1248
|
+
], "dach");
|
|
1249
|
+
const leadCount = z.coerce.number().int().min(1).max(5000).parse(await promptText(rl, "How many leads do you want?", { defaultValue: "100", required: true }));
|
|
1250
|
+
writeWizardLine();
|
|
1251
|
+
const icp = buildVendorIcp(reference.vendorTemplate, market);
|
|
1252
|
+
const icpPath = `./data/${reference.slug}-icp-${market}.json`;
|
|
1253
|
+
const leadPath = buildQualifiedLeadsPath(`${reference.slug}-${market}`);
|
|
1254
|
+
await writeJsonFile(icpPath, icp);
|
|
1255
|
+
const leads = await searchReferenceCompanyLeads(reference, icp, leadCount);
|
|
1256
|
+
await writeJsonFile(leadPath, leads);
|
|
1257
|
+
writeWizardLine(`Saved ICP to ${icpPath}.`);
|
|
1258
|
+
if (leads.length === 0) {
|
|
1259
|
+
writeWizardLine(`No matching leads found for ${reference.companyName} in this workspace.`);
|
|
731
1260
|
return;
|
|
732
1261
|
}
|
|
1262
|
+
writeWizardLine(`Found ${leads.length} matching lead${leads.length === 1 ? "" : "s"}.`);
|
|
1263
|
+
writeWizardLine(`Saved leads to ${leadPath}.`);
|
|
1264
|
+
}
|
|
1265
|
+
async function runTargetCompanyWizard(rl) {
|
|
1266
|
+
writeWizardSection("Target company", "Paste the website or LinkedIn company page for the company you want people from.");
|
|
1267
|
+
const reference = parseCompanyReference(await promptText(rl, "Which company do you want people from?", {
|
|
1268
|
+
required: true
|
|
1269
|
+
}));
|
|
733
1270
|
writeWizardLine();
|
|
734
|
-
await
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1271
|
+
const leadCount = z.coerce.number().int().min(1).max(1000).parse(await promptText(rl, "How many people do you want?", { defaultValue: "25", required: true }));
|
|
1272
|
+
writeWizardLine();
|
|
1273
|
+
const leadPath = buildQualifiedLeadsPath(reference.slug);
|
|
1274
|
+
const leads = await searchTargetCompanyLeads(reference, leadCount);
|
|
1275
|
+
await writeJsonFile(leadPath, leads);
|
|
1276
|
+
if (leads.length === 0) {
|
|
1277
|
+
writeWizardLine(`No qualified leads found for ${reference.label} in this workspace.`);
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
writeWizardLine(`Found ${leads.length} qualified lead${leads.length === 1 ? "" : "s"} at ${reference.companyName}.`);
|
|
1281
|
+
writeWizardLine(`Saved leads to ${leadPath}.`);
|
|
741
1282
|
}
|
|
742
1283
|
async function runOutreachSyncWizard(rl, options) {
|
|
743
1284
|
const target = "instantly";
|
|
@@ -809,7 +1350,7 @@ async function runWizard(options) {
|
|
|
809
1350
|
throw new Error("wizard does not support --json or --quiet.");
|
|
810
1351
|
}
|
|
811
1352
|
writeWizardLine("Salesprompter");
|
|
812
|
-
writeWizardLine("
|
|
1353
|
+
writeWizardLine("Start with a company website or LinkedIn page. I will guide you from there.");
|
|
813
1354
|
writeWizardLine();
|
|
814
1355
|
await ensureWizardSession(options);
|
|
815
1356
|
const rl = createInterface({
|
|
@@ -819,31 +1360,31 @@ async function runWizard(options) {
|
|
|
819
1360
|
try {
|
|
820
1361
|
const flow = await promptChoice(rl, "What do you want help with?", [
|
|
821
1362
|
{
|
|
822
|
-
value: "
|
|
823
|
-
label: "
|
|
824
|
-
description: "
|
|
825
|
-
aliases: ["
|
|
1363
|
+
value: "reference-company",
|
|
1364
|
+
label: "Find leads like one of my customers",
|
|
1365
|
+
description: "Example: I sell for Deel and want similar companies and people",
|
|
1366
|
+
aliases: ["customer", "reference company", "similar companies", "icp", "who to target"]
|
|
826
1367
|
},
|
|
827
1368
|
{
|
|
828
|
-
value: "
|
|
829
|
-
label: "
|
|
830
|
-
description: "
|
|
831
|
-
aliases: ["
|
|
1369
|
+
value: "target-company",
|
|
1370
|
+
label: "Find people at a specific company",
|
|
1371
|
+
description: "Example: find people at deel.com",
|
|
1372
|
+
aliases: ["target company", "company", "find people", "people at a company", "lead generation"]
|
|
832
1373
|
},
|
|
833
1374
|
{
|
|
834
1375
|
value: "outreach-sync",
|
|
835
|
-
label: "
|
|
836
|
-
description: "Use a
|
|
1376
|
+
label: "Push qualified leads to Instantly",
|
|
1377
|
+
description: "Use a saved leads file to fill an Instantly campaign",
|
|
837
1378
|
aliases: ["instantly", "outreach", "send leads", "campaign"]
|
|
838
1379
|
}
|
|
839
|
-
], "
|
|
1380
|
+
], "reference-company");
|
|
840
1381
|
writeWizardLine();
|
|
841
|
-
if (flow === "
|
|
842
|
-
await
|
|
1382
|
+
if (flow === "reference-company") {
|
|
1383
|
+
await runReferenceCompanyWizard(rl);
|
|
843
1384
|
return;
|
|
844
1385
|
}
|
|
845
|
-
if (flow === "
|
|
846
|
-
await
|
|
1386
|
+
if (flow === "target-company") {
|
|
1387
|
+
await runTargetCompanyWizard(rl);
|
|
847
1388
|
return;
|
|
848
1389
|
}
|
|
849
1390
|
await runOutreachSyncWizard(rl);
|
|
@@ -1057,12 +1598,34 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
|
1057
1598
|
if (commandName.startsWith("auth:") || commandName === "wizard") {
|
|
1058
1599
|
return;
|
|
1059
1600
|
}
|
|
1601
|
+
const commandOptions = actionCommand.opts();
|
|
1602
|
+
if (typeof commandOptions === "object" &&
|
|
1603
|
+
commandOptions !== null &&
|
|
1604
|
+
"dryRun" in commandOptions &&
|
|
1605
|
+
Boolean(commandOptions.dryRun)) {
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1060
1608
|
if (shouldBypassAuth()) {
|
|
1061
1609
|
return;
|
|
1062
1610
|
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1611
|
+
try {
|
|
1612
|
+
const session = await requireAuthSession();
|
|
1613
|
+
if (session.expiresAt !== undefined && Date.now() >= Date.parse(session.expiresAt)) {
|
|
1614
|
+
if (canPromptForInteractiveLogin()) {
|
|
1615
|
+
await ensureInteractiveAuthSession(session.apiBaseUrl);
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
throw new Error("session expired. Run `salesprompter auth:login`.");
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
catch (error) {
|
|
1622
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1623
|
+
if ((message.includes("not logged in") || message.includes("session expired")) &&
|
|
1624
|
+
canPromptForInteractiveLogin()) {
|
|
1625
|
+
await ensureInteractiveAuthSession();
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
throw error;
|
|
1066
1629
|
}
|
|
1067
1630
|
});
|
|
1068
1631
|
program
|
|
@@ -1260,6 +1823,278 @@ program
|
|
|
1260
1823
|
});
|
|
1261
1824
|
printOutput({ status: "ok", ...result });
|
|
1262
1825
|
});
|
|
1826
|
+
program
|
|
1827
|
+
.command("linkedin-products:scrape")
|
|
1828
|
+
.description("Resolve a company or LinkedIn product into a LinkedIn product category, scrape that catalog, and upload it to Salesprompter.")
|
|
1829
|
+
.requiredOption("--input <value>", "Company domain, LinkedIn company URL, LinkedIn product URL, LinkedIn category URL, or LinkedIn product search URL")
|
|
1830
|
+
.option("--max-pages <number>", "Maximum LinkedIn category pages to fetch", "25")
|
|
1831
|
+
.option("--limit <number>", "Optional cap on the number of products to keep")
|
|
1832
|
+
.option("--out <path>", "Optional local JSON output path")
|
|
1833
|
+
.option("--skip-details", "Skip product-page enrichment and only keep category-card data", false)
|
|
1834
|
+
.action(async (options) => {
|
|
1835
|
+
const maxPages = z.coerce.number().int().min(1).max(500).parse(options.maxPages);
|
|
1836
|
+
const limit = options.limit === undefined ? undefined : z.coerce.number().int().min(1).max(5000).parse(options.limit);
|
|
1837
|
+
const scrape = await crawlLinkedInProductCategory({
|
|
1838
|
+
input: options.input,
|
|
1839
|
+
maxPages,
|
|
1840
|
+
limit,
|
|
1841
|
+
enrichDetails: !options.skipDetails
|
|
1842
|
+
});
|
|
1843
|
+
const outPath = options.out ?? buildLinkedInProductsOutputPath(scrape.source.category.slug);
|
|
1844
|
+
await writeJsonFile(outPath, {
|
|
1845
|
+
source: scrape.source,
|
|
1846
|
+
items: scrape.items,
|
|
1847
|
+
totalPagesFetched: scrape.totalPagesFetched
|
|
1848
|
+
});
|
|
1849
|
+
let uploaded = null;
|
|
1850
|
+
if (!shouldBypassAuth()) {
|
|
1851
|
+
const session = await requireAuthSession();
|
|
1852
|
+
uploaded = await uploadLinkedInProductsCatalog(session, {
|
|
1853
|
+
source: {
|
|
1854
|
+
input: scrape.source.input,
|
|
1855
|
+
kind: scrape.source.kind,
|
|
1856
|
+
query: scrape.source.query,
|
|
1857
|
+
companyUrl: scrape.source.companyUrl,
|
|
1858
|
+
productUrl: scrape.source.productUrl,
|
|
1859
|
+
category: scrape.source.category
|
|
1860
|
+
},
|
|
1861
|
+
items: scrape.items
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
printOutput({
|
|
1865
|
+
status: "ok",
|
|
1866
|
+
source: scrape.source,
|
|
1867
|
+
totalPagesFetched: scrape.totalPagesFetched,
|
|
1868
|
+
discovered: scrape.items.length,
|
|
1869
|
+
out: outPath,
|
|
1870
|
+
uploaded
|
|
1871
|
+
});
|
|
1872
|
+
});
|
|
1873
|
+
program
|
|
1874
|
+
.command("salesnav:crawl")
|
|
1875
|
+
.description("Adaptively split broad LinkedIn Sales Navigator people searches into exportable slices and store every finished slice through Salesprompter.")
|
|
1876
|
+
.option("--query-url <url>", "Base LinkedIn Sales Navigator people search URL")
|
|
1877
|
+
.option("--job-id <id>", "Resume an existing crawl job by id")
|
|
1878
|
+
.option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
|
|
1879
|
+
.option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
|
|
1880
|
+
.option("--slice-preset <name>", "Slice preset label stored with the export runs", "human-resources-crawl")
|
|
1881
|
+
.option("--max-split-depth <number>", "Maximum number of adaptive split dimensions to use", "6")
|
|
1882
|
+
.option("--max-slices <number>", "Safety cap for total claimed slices in this invocation", "1000")
|
|
1883
|
+
.option("--max-retries <number>", "Retries for non-splitting export failures", "3")
|
|
1884
|
+
.option("--probe-profiles <number>", "Profiles to scrape while probing whether a slice is still too broad", "100")
|
|
1885
|
+
.option("--agent-busy-wait-seconds <number>", "Seconds to wait before retrying when the export agent is already busy", "30")
|
|
1886
|
+
.option("--agent-busy-max-waits <number>", "How many busy-agent waits to tolerate before failing the slice", "20")
|
|
1887
|
+
.option("--out <path>", "Optional local JSON output path")
|
|
1888
|
+
.option("--dry-run", "Preview the adaptive crawl plan without exporting anything", false)
|
|
1889
|
+
.action(async (options) => {
|
|
1890
|
+
const queryUrl = z.string().url().optional().parse(options.queryUrl);
|
|
1891
|
+
const jobId = z.string().uuid().optional().parse(options.jobId);
|
|
1892
|
+
const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
|
|
1893
|
+
const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
|
|
1894
|
+
const maxSplitDepth = z.coerce.number().int().min(1).max(6).parse(options.maxSplitDepth);
|
|
1895
|
+
const maxSlices = z.coerce.number().int().min(1).max(10000).parse(options.maxSlices);
|
|
1896
|
+
const maxRetries = z.coerce.number().int().min(0).max(5).parse(options.maxRetries);
|
|
1897
|
+
const probeProfiles = z.coerce.number().int().min(1).max(2500).parse(options.probeProfiles);
|
|
1898
|
+
const agentBusyWaitSeconds = z.coerce.number().int().min(1).max(300).parse(options.agentBusyWaitSeconds);
|
|
1899
|
+
const agentBusyMaxWaits = z.coerce.number().int().min(0).max(120).parse(options.agentBusyMaxWaits);
|
|
1900
|
+
const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
|
|
1901
|
+
if (effectiveDryRun) {
|
|
1902
|
+
if (jobId) {
|
|
1903
|
+
throw new Error("--dry-run does not support --job-id. Use --query-url instead.");
|
|
1904
|
+
}
|
|
1905
|
+
if (!queryUrl) {
|
|
1906
|
+
throw new Error("Provide --query-url for --dry-run.");
|
|
1907
|
+
}
|
|
1908
|
+
const payload = {
|
|
1909
|
+
status: "ok",
|
|
1910
|
+
dryRun: true,
|
|
1911
|
+
mode: "adaptive",
|
|
1912
|
+
dimensionPreset: "human-resources-adaptive",
|
|
1913
|
+
query: (() => {
|
|
1914
|
+
const preview = buildSalesNavigatorCrawlPreview({
|
|
1915
|
+
sourceQueryUrl: queryUrl,
|
|
1916
|
+
maxResultsPerSearch,
|
|
1917
|
+
numberOfProfiles,
|
|
1918
|
+
slicePreset: options.slicePreset
|
|
1919
|
+
});
|
|
1920
|
+
return {
|
|
1921
|
+
sourceQueryUrl: queryUrl,
|
|
1922
|
+
rootQueryUrl: preview.root.slicedQueryUrl,
|
|
1923
|
+
rootAppliedFilters: preview.root.appliedFilters,
|
|
1924
|
+
dimensionOrder: preview.dimensions.map((dimension) => ({
|
|
1925
|
+
key: dimension.key,
|
|
1926
|
+
filterType: dimension.filterType,
|
|
1927
|
+
valueCount: dimension.values.length
|
|
1928
|
+
})),
|
|
1929
|
+
firstSplitQueries: preview.firstSplit.map((attempt) => ({
|
|
1930
|
+
slicedQueryUrl: attempt.slicedQueryUrl,
|
|
1931
|
+
appliedFilters: attempt.appliedFilters,
|
|
1932
|
+
splitTrail: attempt.splitTrail.map((entry) => ({
|
|
1933
|
+
key: entry.key,
|
|
1934
|
+
filterType: entry.filterType,
|
|
1935
|
+
valueText: entry.value.text
|
|
1936
|
+
}))
|
|
1937
|
+
}))
|
|
1938
|
+
};
|
|
1939
|
+
})()
|
|
1940
|
+
};
|
|
1941
|
+
if (options.out) {
|
|
1942
|
+
await writeJsonFile(options.out, payload);
|
|
1943
|
+
}
|
|
1944
|
+
printOutput(payload);
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
if (Boolean(queryUrl) === Boolean(jobId)) {
|
|
1948
|
+
throw new Error("Provide exactly one of --query-url or --job-id.");
|
|
1949
|
+
}
|
|
1950
|
+
let session = await requireAuthSession();
|
|
1951
|
+
let createResult = null;
|
|
1952
|
+
let resolvedJobId = jobId ?? null;
|
|
1953
|
+
if (queryUrl) {
|
|
1954
|
+
const seed = createSalesNavigatorCrawlSeed({
|
|
1955
|
+
sourceQueryUrl: queryUrl,
|
|
1956
|
+
maxResultsPerSearch,
|
|
1957
|
+
numberOfProfiles,
|
|
1958
|
+
slicePreset: options.slicePreset
|
|
1959
|
+
});
|
|
1960
|
+
const created = await createOrResumeSalesNavigatorCrawlJob(session, {
|
|
1961
|
+
sourceQueryUrl: queryUrl,
|
|
1962
|
+
slicePreset: options.slicePreset,
|
|
1963
|
+
maxResultsPerSearch,
|
|
1964
|
+
numberOfProfiles,
|
|
1965
|
+
rootSlice: {
|
|
1966
|
+
slicedQueryUrl: seed.slicedQueryUrl,
|
|
1967
|
+
appliedFilters: seed.appliedFilters,
|
|
1968
|
+
depth: seed.depth,
|
|
1969
|
+
splitTrail: seed.splitTrail
|
|
1970
|
+
}
|
|
1971
|
+
});
|
|
1972
|
+
session = created.session;
|
|
1973
|
+
createResult = {
|
|
1974
|
+
resumed: created.value.resumed,
|
|
1975
|
+
job: created.value.job
|
|
1976
|
+
};
|
|
1977
|
+
resolvedJobId = created.value.job.id;
|
|
1978
|
+
}
|
|
1979
|
+
else {
|
|
1980
|
+
const status = await getSalesNavigatorCrawlStatus(session, resolvedJobId);
|
|
1981
|
+
session = status.session;
|
|
1982
|
+
}
|
|
1983
|
+
if (!resolvedJobId) {
|
|
1984
|
+
throw new Error("Failed to determine Sales Navigator crawl job id.");
|
|
1985
|
+
}
|
|
1986
|
+
const crawl = await executeSalesNavigatorCrawlJob(session, resolvedJobId, {
|
|
1987
|
+
maxSplitDepth,
|
|
1988
|
+
maxSlices,
|
|
1989
|
+
maxRetries,
|
|
1990
|
+
probeProfiles,
|
|
1991
|
+
agentBusyWaitSeconds,
|
|
1992
|
+
agentBusyMaxWaits
|
|
1993
|
+
});
|
|
1994
|
+
const payload = {
|
|
1995
|
+
status: "ok",
|
|
1996
|
+
dryRun: false,
|
|
1997
|
+
mode: "durable",
|
|
1998
|
+
jobId: resolvedJobId,
|
|
1999
|
+
resumed: createResult?.resumed ?? true,
|
|
2000
|
+
sourceQueryUrl: crawl.job.sourceQueryUrl,
|
|
2001
|
+
slicePreset: crawl.job.slicePreset,
|
|
2002
|
+
maxResultsPerSearch: crawl.job.maxResultsPerSearch,
|
|
2003
|
+
numberOfProfiles: crawl.job.numberOfProfiles,
|
|
2004
|
+
claimedSlices: crawl.claimedSlices,
|
|
2005
|
+
truncated: crawl.truncated,
|
|
2006
|
+
job: crawl.job,
|
|
2007
|
+
activeSlice: crawl.activeSlice
|
|
2008
|
+
? {
|
|
2009
|
+
id: crawl.activeSlice.id,
|
|
2010
|
+
slicedQueryUrl: crawl.activeSlice.slicedQueryUrl,
|
|
2011
|
+
depth: crawl.activeSlice.depth,
|
|
2012
|
+
splitTrail: formatSalesNavigatorSplitTrail(crawl.activeSlice.splitTrail),
|
|
2013
|
+
retryCount: crawl.activeSlice.retryCount,
|
|
2014
|
+
cookieRetryCount: crawl.activeSlice.cookieRetryCount,
|
|
2015
|
+
resultRetryCount: crawl.activeSlice.resultRetryCount
|
|
2016
|
+
}
|
|
2017
|
+
: null,
|
|
2018
|
+
lastOutcome: crawl.lastOutcome
|
|
2019
|
+
};
|
|
2020
|
+
if (options.out) {
|
|
2021
|
+
await writeJsonFile(options.out, payload);
|
|
2022
|
+
}
|
|
2023
|
+
printOutput(payload);
|
|
2024
|
+
});
|
|
2025
|
+
program
|
|
2026
|
+
.command("salesnav:crawl:status")
|
|
2027
|
+
.description("Return the current status of a durable Sales Navigator crawl job.")
|
|
2028
|
+
.requiredOption("--job-id <id>", "Sales Navigator crawl job id")
|
|
2029
|
+
.action(async (options) => {
|
|
2030
|
+
const jobId = z.string().uuid().parse(options.jobId);
|
|
2031
|
+
let session = await requireAuthSession();
|
|
2032
|
+
const status = await getSalesNavigatorCrawlStatus(session, jobId);
|
|
2033
|
+
session = status.session;
|
|
2034
|
+
void session;
|
|
2035
|
+
printOutput({
|
|
2036
|
+
status: "ok",
|
|
2037
|
+
jobId,
|
|
2038
|
+
job: status.value.job
|
|
2039
|
+
});
|
|
2040
|
+
});
|
|
2041
|
+
program
|
|
2042
|
+
.command("salesnav:export")
|
|
2043
|
+
.description("Apply the default Sales Navigator HR slice filters to one or more people-search URLs, then export and store the results through Salesprompter.")
|
|
2044
|
+
.requiredOption("--query-url <url>", "Base LinkedIn Sales Navigator people search URL", collectStringOptionValue, [])
|
|
2045
|
+
.option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
|
|
2046
|
+
.option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
|
|
2047
|
+
.option("--slice-preset <name>", "Slice preset label stored with the export run", "human-resources-default")
|
|
2048
|
+
.option("--out <path>", "Optional local JSON output path")
|
|
2049
|
+
.option("--dry-run", "Only generate sliced query URLs without exporting them", false)
|
|
2050
|
+
.action(async (options) => {
|
|
2051
|
+
const queryUrls = z.array(z.string().url()).min(1).parse(options.queryUrl);
|
|
2052
|
+
const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
|
|
2053
|
+
const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
|
|
2054
|
+
const prepared = queryUrls.map((queryUrl) => buildSalesNavigatorPeopleSlice(queryUrl));
|
|
2055
|
+
const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
|
|
2056
|
+
if (effectiveDryRun) {
|
|
2057
|
+
const payload = {
|
|
2058
|
+
status: "ok",
|
|
2059
|
+
dryRun: true,
|
|
2060
|
+
queries: prepared.map((item) => ({
|
|
2061
|
+
sourceQueryUrl: item.sourceQueryUrl,
|
|
2062
|
+
slicedQueryUrl: item.slicedQueryUrl,
|
|
2063
|
+
appliedFilters: item.appliedFilters,
|
|
2064
|
+
maxResultsPerSearch,
|
|
2065
|
+
numberOfProfiles,
|
|
2066
|
+
slicePreset: options.slicePreset
|
|
2067
|
+
}))
|
|
2068
|
+
};
|
|
2069
|
+
if (options.out) {
|
|
2070
|
+
await writeJsonFile(options.out, payload);
|
|
2071
|
+
}
|
|
2072
|
+
printOutput(payload);
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
const session = await requireAuthSession();
|
|
2076
|
+
const exported = [];
|
|
2077
|
+
for (const item of prepared) {
|
|
2078
|
+
const result = await runSalesNavigatorExport(session, {
|
|
2079
|
+
sourceQueryUrl: item.sourceQueryUrl,
|
|
2080
|
+
slicedQueryUrl: item.slicedQueryUrl,
|
|
2081
|
+
appliedFilters: item.appliedFilters,
|
|
2082
|
+
maxResultsPerSearch,
|
|
2083
|
+
numberOfProfiles,
|
|
2084
|
+
slicePreset: options.slicePreset
|
|
2085
|
+
});
|
|
2086
|
+
exported.push(result);
|
|
2087
|
+
}
|
|
2088
|
+
const payload = {
|
|
2089
|
+
status: "ok",
|
|
2090
|
+
dryRun: false,
|
|
2091
|
+
queries: exported
|
|
2092
|
+
};
|
|
2093
|
+
if (options.out) {
|
|
2094
|
+
await writeJsonFile(options.out, payload);
|
|
2095
|
+
}
|
|
2096
|
+
printOutput(payload);
|
|
2097
|
+
});
|
|
1263
2098
|
program
|
|
1264
2099
|
.command("leads:lookup:bq")
|
|
1265
2100
|
.description("Build a BigQuery lead lookup from an ICP and optionally execute it with the bq CLI.")
|
|
@@ -1361,6 +2196,53 @@ program
|
|
|
1361
2196
|
sourceTables: report.sourceTables
|
|
1362
2197
|
});
|
|
1363
2198
|
});
|
|
2199
|
+
program
|
|
2200
|
+
.command("leadlists:direct-export:bq")
|
|
2201
|
+
.description("Export upstream leads directly from leadLists -> linkedin_contacts -> linkedin_companies and segment them.")
|
|
2202
|
+
.requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
|
|
2203
|
+
.option("--market <market>", "global|europe|dach", "dach")
|
|
2204
|
+
.option("--limit <number>", "Max rows to export", "20000")
|
|
2205
|
+
.requiredOption("--out-dir <path>", "Output directory for raw and segmented files")
|
|
2206
|
+
.option("--sql-out <path>", "Optional file path for the generated SQL")
|
|
2207
|
+
.action(async (options) => {
|
|
2208
|
+
const vendor = z.enum(["deel"]).parse(options.vendor);
|
|
2209
|
+
const market = z.enum(["global", "europe", "dach"]).parse(options.market);
|
|
2210
|
+
const limit = z.coerce.number().int().min(1).max(100000).parse(options.limit);
|
|
2211
|
+
const sql = buildDirectPathLeadExportSql(vendor, market, limit);
|
|
2212
|
+
if (options.sqlOut) {
|
|
2213
|
+
await writeTextFile(options.sqlOut, `${sql}\n`);
|
|
2214
|
+
}
|
|
2215
|
+
const rows = await runBigQueryRows(sql, { maxRows: limit });
|
|
2216
|
+
const normalizedRows = normalizeDirectPathRows(rows);
|
|
2217
|
+
const pack = segmentDirectPathRows(vendor, market, normalizedRows);
|
|
2218
|
+
const baseSlug = `${slugify(vendor)}-direct-${market}`;
|
|
2219
|
+
const rawPath = path.join(options.outDir, `${baseSlug}-raw.json`);
|
|
2220
|
+
const packPath = path.join(options.outDir, `${baseSlug}-segments.json`);
|
|
2221
|
+
const leadershipPath = path.join(options.outDir, `${baseSlug}-leadership.json`);
|
|
2222
|
+
const payrollPath = path.join(options.outDir, `${baseSlug}-payroll-people-services.json`);
|
|
2223
|
+
const broaderPath = path.join(options.outDir, `${baseSlug}-broader-hr.json`);
|
|
2224
|
+
await writeJsonFile(rawPath, normalizedRows);
|
|
2225
|
+
await writeJsonFile(packPath, pack);
|
|
2226
|
+
await writeJsonFile(leadershipPath, pack.segments.leadership);
|
|
2227
|
+
await writeJsonFile(payrollPath, pack.segments["payroll-people-services"]);
|
|
2228
|
+
await writeJsonFile(broaderPath, pack.segments["broader-hr"]);
|
|
2229
|
+
printOutput({
|
|
2230
|
+
status: "ok",
|
|
2231
|
+
vendor,
|
|
2232
|
+
market,
|
|
2233
|
+
rowCount: normalizedRows.length,
|
|
2234
|
+
distinctContacts: pack.distinctContacts,
|
|
2235
|
+
distinctCompanies: pack.distinctCompanies,
|
|
2236
|
+
segmentCounts: pack.summary.segmentCounts,
|
|
2237
|
+
outDir: options.outDir,
|
|
2238
|
+
raw: rawPath,
|
|
2239
|
+
pack: packPath,
|
|
2240
|
+
leadership: leadershipPath,
|
|
2241
|
+
payrollPeopleServices: payrollPath,
|
|
2242
|
+
broaderHr: broaderPath,
|
|
2243
|
+
sqlOut: options.sqlOut ?? null
|
|
2244
|
+
});
|
|
2245
|
+
});
|
|
1364
2246
|
program
|
|
1365
2247
|
.command("leadlists:funnel:bq")
|
|
1366
2248
|
.description("Build an upstream lead-list funnel report for a vendor/market.")
|