salesprompter-cli 0.1.17 → 0.1.18
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 +1179 -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,612 @@ 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
|
+
let activeSlice = null;
|
|
1121
|
+
let job = null;
|
|
1122
|
+
let lastOutcome = null;
|
|
1123
|
+
while (claimedSlices < options.maxSlices) {
|
|
1124
|
+
const claimed = await claimNextSalesNavigatorCrawlSlice(currentSession, jobId);
|
|
1125
|
+
currentSession = claimed.session;
|
|
1126
|
+
job = claimed.value.job;
|
|
1127
|
+
if (!claimed.value.slice) {
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
const slice = claimed.value.slice;
|
|
1131
|
+
activeSlice = slice;
|
|
1132
|
+
claimedSlices += 1;
|
|
1133
|
+
if (!runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
|
|
1134
|
+
process.stderr.write(`Processing Sales Navigator slice ${claimedSlices}/${options.maxSlices}: ${slice.slicedQueryUrl}\n`);
|
|
1135
|
+
}
|
|
1136
|
+
try {
|
|
1137
|
+
const result = await runSalesNavigatorCrawlAttempt(currentSession, buildSalesNavigatorCrawlAttemptFromClaimedSlice(slice), {
|
|
1138
|
+
maxSplitDepth: options.maxSplitDepth,
|
|
1139
|
+
probeProfiles: options.probeProfiles,
|
|
1140
|
+
agentBusyWaitSeconds: options.agentBusyWaitSeconds,
|
|
1141
|
+
agentBusyMaxWaits: options.agentBusyMaxWaits
|
|
1142
|
+
}, {
|
|
1143
|
+
crawlJobId: jobId,
|
|
1144
|
+
crawlSliceId: slice.id
|
|
1145
|
+
});
|
|
1146
|
+
const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, {
|
|
1147
|
+
sliceId: slice.id,
|
|
1148
|
+
outcome: "exported",
|
|
1149
|
+
totalResults: result.totalResults ?? null,
|
|
1150
|
+
exportRunId: result.runId,
|
|
1151
|
+
importedPeople: result.imported,
|
|
1152
|
+
upsertedPeople: result.upserted
|
|
1153
|
+
});
|
|
1154
|
+
currentSession = reported.session;
|
|
1155
|
+
job = reported.value.job;
|
|
1156
|
+
lastOutcome = {
|
|
1157
|
+
outcome: "exported",
|
|
1158
|
+
runId: result.runId,
|
|
1159
|
+
totalResults: result.totalResults ?? null
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
catch (error) {
|
|
1163
|
+
const payload = buildSalesNavigatorSliceFailureReport(slice, error, {
|
|
1164
|
+
maxSplitDepth: options.maxSplitDepth,
|
|
1165
|
+
maxRetries: options.maxRetries
|
|
1166
|
+
});
|
|
1167
|
+
const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, payload);
|
|
1168
|
+
currentSession = reported.session;
|
|
1169
|
+
job = reported.value.job;
|
|
1170
|
+
lastOutcome = {
|
|
1171
|
+
outcome: payload.outcome,
|
|
1172
|
+
runId: payload.exportRunId,
|
|
1173
|
+
error: payload.error,
|
|
1174
|
+
errorCode: payload.errorCode,
|
|
1175
|
+
totalResults: payload.totalResults
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (!job) {
|
|
1180
|
+
const status = await getSalesNavigatorCrawlStatus(currentSession, jobId);
|
|
1181
|
+
currentSession = status.session;
|
|
1182
|
+
job = status.value.job;
|
|
1183
|
+
}
|
|
1184
|
+
return {
|
|
1185
|
+
session: currentSession,
|
|
1186
|
+
job,
|
|
1187
|
+
claimedSlices,
|
|
1188
|
+
truncated: claimedSlices >= options.maxSlices && (job.queuedSlices > 0 || job.runningSlices > 0),
|
|
1189
|
+
activeSlice,
|
|
1190
|
+
lastOutcome
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
async function searchReferenceCompanyLeads(reference, icp, limit) {
|
|
1194
|
+
if (shouldBypassAuth()) {
|
|
1195
|
+
const fallbackTargetDomain = reference.domain ?? `${reference.slug}.com`;
|
|
1196
|
+
const result = await leadProvider.generateLeads(icp, limit, {
|
|
1197
|
+
companyDomain: fallbackTargetDomain,
|
|
1198
|
+
companyName: reference.companyName
|
|
678
1199
|
});
|
|
679
|
-
|
|
680
|
-
writeWizardLine(`Using profile from ${icpPath}.`);
|
|
1200
|
+
return result.leads;
|
|
681
1201
|
}
|
|
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
|
|
1202
|
+
const session = await requireAuthSession();
|
|
1203
|
+
return await fetchWorkspaceLeadSearch(session, {
|
|
1204
|
+
mode: "reference-company",
|
|
1205
|
+
icp,
|
|
1206
|
+
limit
|
|
706
1207
|
});
|
|
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}.`);
|
|
1208
|
+
}
|
|
1209
|
+
async function searchTargetCompanyLeads(reference, limit) {
|
|
1210
|
+
if (shouldBypassAuth()) {
|
|
1211
|
+
const fallbackTargetDomain = reference.domain ?? `${reference.slug}.com`;
|
|
1212
|
+
const result = await leadProvider.generateLeads(IcpSchema.parse({ name: `${reference.companyName} qualified leads` }), limit, {
|
|
1213
|
+
companyDomain: fallbackTargetDomain,
|
|
1214
|
+
companyName: reference.companyName
|
|
1215
|
+
});
|
|
1216
|
+
return result.leads;
|
|
722
1217
|
}
|
|
1218
|
+
const session = await requireAuthSession();
|
|
1219
|
+
return await fetchWorkspaceLeadSearch(session, {
|
|
1220
|
+
mode: "target-company",
|
|
1221
|
+
domain: reference.domain,
|
|
1222
|
+
linkedinCompanyPage: reference.linkedinCompanyPage,
|
|
1223
|
+
limit
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
async function runReferenceCompanyWizard(rl) {
|
|
1227
|
+
writeWizardSection("Reference company", "Paste the website or LinkedIn company page for the company you sell for.");
|
|
1228
|
+
const reference = parseCompanyReference(await promptText(rl, "Which company are you selling for?", {
|
|
1229
|
+
required: true
|
|
1230
|
+
}));
|
|
723
1231
|
writeWizardLine();
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
if (execute) {
|
|
727
|
-
lookupArgs.push("--execute", "--out", rawPath, "--lead-out", leadPath);
|
|
1232
|
+
if (reference.vendorTemplate !== "deel") {
|
|
1233
|
+
throw new Error("Automatic company-to-ICP matching is available for Deel right now. Try deel.com or the Deel LinkedIn company page.");
|
|
728
1234
|
}
|
|
729
|
-
|
|
730
|
-
|
|
1235
|
+
writeWizardSection("Find matching leads", `Using the built-in ${reference.companyName} profile to search your workspace data.`);
|
|
1236
|
+
const market = await promptChoice(rl, "Where do you want to search?", [
|
|
1237
|
+
{ value: "dach", label: "DACH", description: "Germany, Austria, Switzerland" },
|
|
1238
|
+
{ value: "europe", label: "Europe" },
|
|
1239
|
+
{ value: "global", label: "Global" }
|
|
1240
|
+
], "dach");
|
|
1241
|
+
const leadCount = z.coerce.number().int().min(1).max(5000).parse(await promptText(rl, "How many leads do you want?", { defaultValue: "100", required: true }));
|
|
1242
|
+
writeWizardLine();
|
|
1243
|
+
const icp = buildVendorIcp(reference.vendorTemplate, market);
|
|
1244
|
+
const icpPath = `./data/${reference.slug}-icp-${market}.json`;
|
|
1245
|
+
const leadPath = buildQualifiedLeadsPath(`${reference.slug}-${market}`);
|
|
1246
|
+
await writeJsonFile(icpPath, icp);
|
|
1247
|
+
const leads = await searchReferenceCompanyLeads(reference, icp, leadCount);
|
|
1248
|
+
await writeJsonFile(leadPath, leads);
|
|
1249
|
+
writeWizardLine(`Saved ICP to ${icpPath}.`);
|
|
1250
|
+
if (leads.length === 0) {
|
|
1251
|
+
writeWizardLine(`No matching leads found for ${reference.companyName} in this workspace.`);
|
|
731
1252
|
return;
|
|
732
1253
|
}
|
|
1254
|
+
writeWizardLine(`Found ${leads.length} matching lead${leads.length === 1 ? "" : "s"}.`);
|
|
1255
|
+
writeWizardLine(`Saved leads to ${leadPath}.`);
|
|
1256
|
+
}
|
|
1257
|
+
async function runTargetCompanyWizard(rl) {
|
|
1258
|
+
writeWizardSection("Target company", "Paste the website or LinkedIn company page for the company you want people from.");
|
|
1259
|
+
const reference = parseCompanyReference(await promptText(rl, "Which company do you want people from?", {
|
|
1260
|
+
required: true
|
|
1261
|
+
}));
|
|
733
1262
|
writeWizardLine();
|
|
734
|
-
await
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1263
|
+
const leadCount = z.coerce.number().int().min(1).max(1000).parse(await promptText(rl, "How many people do you want?", { defaultValue: "25", required: true }));
|
|
1264
|
+
writeWizardLine();
|
|
1265
|
+
const leadPath = buildQualifiedLeadsPath(reference.slug);
|
|
1266
|
+
const leads = await searchTargetCompanyLeads(reference, leadCount);
|
|
1267
|
+
await writeJsonFile(leadPath, leads);
|
|
1268
|
+
if (leads.length === 0) {
|
|
1269
|
+
writeWizardLine(`No qualified leads found for ${reference.label} in this workspace.`);
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
writeWizardLine(`Found ${leads.length} qualified lead${leads.length === 1 ? "" : "s"} at ${reference.companyName}.`);
|
|
1273
|
+
writeWizardLine(`Saved leads to ${leadPath}.`);
|
|
741
1274
|
}
|
|
742
1275
|
async function runOutreachSyncWizard(rl, options) {
|
|
743
1276
|
const target = "instantly";
|
|
@@ -809,7 +1342,7 @@ async function runWizard(options) {
|
|
|
809
1342
|
throw new Error("wizard does not support --json or --quiet.");
|
|
810
1343
|
}
|
|
811
1344
|
writeWizardLine("Salesprompter");
|
|
812
|
-
writeWizardLine("
|
|
1345
|
+
writeWizardLine("Start with a company website or LinkedIn page. I will guide you from there.");
|
|
813
1346
|
writeWizardLine();
|
|
814
1347
|
await ensureWizardSession(options);
|
|
815
1348
|
const rl = createInterface({
|
|
@@ -819,31 +1352,31 @@ async function runWizard(options) {
|
|
|
819
1352
|
try {
|
|
820
1353
|
const flow = await promptChoice(rl, "What do you want help with?", [
|
|
821
1354
|
{
|
|
822
|
-
value: "
|
|
823
|
-
label: "
|
|
824
|
-
description: "
|
|
825
|
-
aliases: ["
|
|
1355
|
+
value: "reference-company",
|
|
1356
|
+
label: "Find leads like one of my customers",
|
|
1357
|
+
description: "Example: I sell for Deel and want similar companies and people",
|
|
1358
|
+
aliases: ["customer", "reference company", "similar companies", "icp", "who to target"]
|
|
826
1359
|
},
|
|
827
1360
|
{
|
|
828
|
-
value: "
|
|
829
|
-
label: "
|
|
830
|
-
description: "
|
|
831
|
-
aliases: ["
|
|
1361
|
+
value: "target-company",
|
|
1362
|
+
label: "Find people at a specific company",
|
|
1363
|
+
description: "Example: find people at deel.com",
|
|
1364
|
+
aliases: ["target company", "company", "find people", "people at a company", "lead generation"]
|
|
832
1365
|
},
|
|
833
1366
|
{
|
|
834
1367
|
value: "outreach-sync",
|
|
835
|
-
label: "
|
|
836
|
-
description: "Use a
|
|
1368
|
+
label: "Push qualified leads to Instantly",
|
|
1369
|
+
description: "Use a saved leads file to fill an Instantly campaign",
|
|
837
1370
|
aliases: ["instantly", "outreach", "send leads", "campaign"]
|
|
838
1371
|
}
|
|
839
|
-
], "
|
|
1372
|
+
], "reference-company");
|
|
840
1373
|
writeWizardLine();
|
|
841
|
-
if (flow === "
|
|
842
|
-
await
|
|
1374
|
+
if (flow === "reference-company") {
|
|
1375
|
+
await runReferenceCompanyWizard(rl);
|
|
843
1376
|
return;
|
|
844
1377
|
}
|
|
845
|
-
if (flow === "
|
|
846
|
-
await
|
|
1378
|
+
if (flow === "target-company") {
|
|
1379
|
+
await runTargetCompanyWizard(rl);
|
|
847
1380
|
return;
|
|
848
1381
|
}
|
|
849
1382
|
await runOutreachSyncWizard(rl);
|
|
@@ -1057,12 +1590,34 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
|
1057
1590
|
if (commandName.startsWith("auth:") || commandName === "wizard") {
|
|
1058
1591
|
return;
|
|
1059
1592
|
}
|
|
1593
|
+
const commandOptions = actionCommand.opts();
|
|
1594
|
+
if (typeof commandOptions === "object" &&
|
|
1595
|
+
commandOptions !== null &&
|
|
1596
|
+
"dryRun" in commandOptions &&
|
|
1597
|
+
Boolean(commandOptions.dryRun)) {
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1060
1600
|
if (shouldBypassAuth()) {
|
|
1061
1601
|
return;
|
|
1062
1602
|
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1603
|
+
try {
|
|
1604
|
+
const session = await requireAuthSession();
|
|
1605
|
+
if (session.expiresAt !== undefined && Date.now() >= Date.parse(session.expiresAt)) {
|
|
1606
|
+
if (canPromptForInteractiveLogin()) {
|
|
1607
|
+
await ensureInteractiveAuthSession(session.apiBaseUrl);
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
throw new Error("session expired. Run `salesprompter auth:login`.");
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
catch (error) {
|
|
1614
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1615
|
+
if ((message.includes("not logged in") || message.includes("session expired")) &&
|
|
1616
|
+
canPromptForInteractiveLogin()) {
|
|
1617
|
+
await ensureInteractiveAuthSession();
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
throw error;
|
|
1066
1621
|
}
|
|
1067
1622
|
});
|
|
1068
1623
|
program
|
|
@@ -1260,6 +1815,278 @@ program
|
|
|
1260
1815
|
});
|
|
1261
1816
|
printOutput({ status: "ok", ...result });
|
|
1262
1817
|
});
|
|
1818
|
+
program
|
|
1819
|
+
.command("linkedin-products:scrape")
|
|
1820
|
+
.description("Resolve a company or LinkedIn product into a LinkedIn product category, scrape that catalog, and upload it to Salesprompter.")
|
|
1821
|
+
.requiredOption("--input <value>", "Company domain, LinkedIn company URL, LinkedIn product URL, LinkedIn category URL, or LinkedIn product search URL")
|
|
1822
|
+
.option("--max-pages <number>", "Maximum LinkedIn category pages to fetch", "25")
|
|
1823
|
+
.option("--limit <number>", "Optional cap on the number of products to keep")
|
|
1824
|
+
.option("--out <path>", "Optional local JSON output path")
|
|
1825
|
+
.option("--skip-details", "Skip product-page enrichment and only keep category-card data", false)
|
|
1826
|
+
.action(async (options) => {
|
|
1827
|
+
const maxPages = z.coerce.number().int().min(1).max(500).parse(options.maxPages);
|
|
1828
|
+
const limit = options.limit === undefined ? undefined : z.coerce.number().int().min(1).max(5000).parse(options.limit);
|
|
1829
|
+
const scrape = await crawlLinkedInProductCategory({
|
|
1830
|
+
input: options.input,
|
|
1831
|
+
maxPages,
|
|
1832
|
+
limit,
|
|
1833
|
+
enrichDetails: !options.skipDetails
|
|
1834
|
+
});
|
|
1835
|
+
const outPath = options.out ?? buildLinkedInProductsOutputPath(scrape.source.category.slug);
|
|
1836
|
+
await writeJsonFile(outPath, {
|
|
1837
|
+
source: scrape.source,
|
|
1838
|
+
items: scrape.items,
|
|
1839
|
+
totalPagesFetched: scrape.totalPagesFetched
|
|
1840
|
+
});
|
|
1841
|
+
let uploaded = null;
|
|
1842
|
+
if (!shouldBypassAuth()) {
|
|
1843
|
+
const session = await requireAuthSession();
|
|
1844
|
+
uploaded = await uploadLinkedInProductsCatalog(session, {
|
|
1845
|
+
source: {
|
|
1846
|
+
input: scrape.source.input,
|
|
1847
|
+
kind: scrape.source.kind,
|
|
1848
|
+
query: scrape.source.query,
|
|
1849
|
+
companyUrl: scrape.source.companyUrl,
|
|
1850
|
+
productUrl: scrape.source.productUrl,
|
|
1851
|
+
category: scrape.source.category
|
|
1852
|
+
},
|
|
1853
|
+
items: scrape.items
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
printOutput({
|
|
1857
|
+
status: "ok",
|
|
1858
|
+
source: scrape.source,
|
|
1859
|
+
totalPagesFetched: scrape.totalPagesFetched,
|
|
1860
|
+
discovered: scrape.items.length,
|
|
1861
|
+
out: outPath,
|
|
1862
|
+
uploaded
|
|
1863
|
+
});
|
|
1864
|
+
});
|
|
1865
|
+
program
|
|
1866
|
+
.command("salesnav:crawl")
|
|
1867
|
+
.description("Adaptively split broad LinkedIn Sales Navigator people searches into exportable slices and store every finished slice through Salesprompter.")
|
|
1868
|
+
.option("--query-url <url>", "Base LinkedIn Sales Navigator people search URL")
|
|
1869
|
+
.option("--job-id <id>", "Resume an existing crawl job by id")
|
|
1870
|
+
.option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
|
|
1871
|
+
.option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
|
|
1872
|
+
.option("--slice-preset <name>", "Slice preset label stored with the export runs", "human-resources-crawl")
|
|
1873
|
+
.option("--max-split-depth <number>", "Maximum number of adaptive split dimensions to use", "6")
|
|
1874
|
+
.option("--max-slices <number>", "Safety cap for total claimed slices in this invocation", "1000")
|
|
1875
|
+
.option("--max-retries <number>", "Retries for non-splitting export failures", "3")
|
|
1876
|
+
.option("--probe-profiles <number>", "Profiles to scrape while probing whether a slice is still too broad", "100")
|
|
1877
|
+
.option("--agent-busy-wait-seconds <number>", "Seconds to wait before retrying when the export agent is already busy", "30")
|
|
1878
|
+
.option("--agent-busy-max-waits <number>", "How many busy-agent waits to tolerate before failing the slice", "20")
|
|
1879
|
+
.option("--out <path>", "Optional local JSON output path")
|
|
1880
|
+
.option("--dry-run", "Preview the adaptive crawl plan without exporting anything", false)
|
|
1881
|
+
.action(async (options) => {
|
|
1882
|
+
const queryUrl = z.string().url().optional().parse(options.queryUrl);
|
|
1883
|
+
const jobId = z.string().uuid().optional().parse(options.jobId);
|
|
1884
|
+
const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
|
|
1885
|
+
const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
|
|
1886
|
+
const maxSplitDepth = z.coerce.number().int().min(1).max(6).parse(options.maxSplitDepth);
|
|
1887
|
+
const maxSlices = z.coerce.number().int().min(1).max(10000).parse(options.maxSlices);
|
|
1888
|
+
const maxRetries = z.coerce.number().int().min(0).max(5).parse(options.maxRetries);
|
|
1889
|
+
const probeProfiles = z.coerce.number().int().min(1).max(2500).parse(options.probeProfiles);
|
|
1890
|
+
const agentBusyWaitSeconds = z.coerce.number().int().min(1).max(300).parse(options.agentBusyWaitSeconds);
|
|
1891
|
+
const agentBusyMaxWaits = z.coerce.number().int().min(0).max(120).parse(options.agentBusyMaxWaits);
|
|
1892
|
+
const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
|
|
1893
|
+
if (effectiveDryRun) {
|
|
1894
|
+
if (jobId) {
|
|
1895
|
+
throw new Error("--dry-run does not support --job-id. Use --query-url instead.");
|
|
1896
|
+
}
|
|
1897
|
+
if (!queryUrl) {
|
|
1898
|
+
throw new Error("Provide --query-url for --dry-run.");
|
|
1899
|
+
}
|
|
1900
|
+
const payload = {
|
|
1901
|
+
status: "ok",
|
|
1902
|
+
dryRun: true,
|
|
1903
|
+
mode: "adaptive",
|
|
1904
|
+
dimensionPreset: "human-resources-adaptive",
|
|
1905
|
+
query: (() => {
|
|
1906
|
+
const preview = buildSalesNavigatorCrawlPreview({
|
|
1907
|
+
sourceQueryUrl: queryUrl,
|
|
1908
|
+
maxResultsPerSearch,
|
|
1909
|
+
numberOfProfiles,
|
|
1910
|
+
slicePreset: options.slicePreset
|
|
1911
|
+
});
|
|
1912
|
+
return {
|
|
1913
|
+
sourceQueryUrl: queryUrl,
|
|
1914
|
+
rootQueryUrl: preview.root.slicedQueryUrl,
|
|
1915
|
+
rootAppliedFilters: preview.root.appliedFilters,
|
|
1916
|
+
dimensionOrder: preview.dimensions.map((dimension) => ({
|
|
1917
|
+
key: dimension.key,
|
|
1918
|
+
filterType: dimension.filterType,
|
|
1919
|
+
valueCount: dimension.values.length
|
|
1920
|
+
})),
|
|
1921
|
+
firstSplitQueries: preview.firstSplit.map((attempt) => ({
|
|
1922
|
+
slicedQueryUrl: attempt.slicedQueryUrl,
|
|
1923
|
+
appliedFilters: attempt.appliedFilters,
|
|
1924
|
+
splitTrail: attempt.splitTrail.map((entry) => ({
|
|
1925
|
+
key: entry.key,
|
|
1926
|
+
filterType: entry.filterType,
|
|
1927
|
+
valueText: entry.value.text
|
|
1928
|
+
}))
|
|
1929
|
+
}))
|
|
1930
|
+
};
|
|
1931
|
+
})()
|
|
1932
|
+
};
|
|
1933
|
+
if (options.out) {
|
|
1934
|
+
await writeJsonFile(options.out, payload);
|
|
1935
|
+
}
|
|
1936
|
+
printOutput(payload);
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
if (Boolean(queryUrl) === Boolean(jobId)) {
|
|
1940
|
+
throw new Error("Provide exactly one of --query-url or --job-id.");
|
|
1941
|
+
}
|
|
1942
|
+
let session = await requireAuthSession();
|
|
1943
|
+
let createResult = null;
|
|
1944
|
+
let resolvedJobId = jobId ?? null;
|
|
1945
|
+
if (queryUrl) {
|
|
1946
|
+
const seed = createSalesNavigatorCrawlSeed({
|
|
1947
|
+
sourceQueryUrl: queryUrl,
|
|
1948
|
+
maxResultsPerSearch,
|
|
1949
|
+
numberOfProfiles,
|
|
1950
|
+
slicePreset: options.slicePreset
|
|
1951
|
+
});
|
|
1952
|
+
const created = await createOrResumeSalesNavigatorCrawlJob(session, {
|
|
1953
|
+
sourceQueryUrl: queryUrl,
|
|
1954
|
+
slicePreset: options.slicePreset,
|
|
1955
|
+
maxResultsPerSearch,
|
|
1956
|
+
numberOfProfiles,
|
|
1957
|
+
rootSlice: {
|
|
1958
|
+
slicedQueryUrl: seed.slicedQueryUrl,
|
|
1959
|
+
appliedFilters: seed.appliedFilters,
|
|
1960
|
+
depth: seed.depth,
|
|
1961
|
+
splitTrail: seed.splitTrail
|
|
1962
|
+
}
|
|
1963
|
+
});
|
|
1964
|
+
session = created.session;
|
|
1965
|
+
createResult = {
|
|
1966
|
+
resumed: created.value.resumed,
|
|
1967
|
+
job: created.value.job
|
|
1968
|
+
};
|
|
1969
|
+
resolvedJobId = created.value.job.id;
|
|
1970
|
+
}
|
|
1971
|
+
else {
|
|
1972
|
+
const status = await getSalesNavigatorCrawlStatus(session, resolvedJobId);
|
|
1973
|
+
session = status.session;
|
|
1974
|
+
}
|
|
1975
|
+
if (!resolvedJobId) {
|
|
1976
|
+
throw new Error("Failed to determine Sales Navigator crawl job id.");
|
|
1977
|
+
}
|
|
1978
|
+
const crawl = await executeSalesNavigatorCrawlJob(session, resolvedJobId, {
|
|
1979
|
+
maxSplitDepth,
|
|
1980
|
+
maxSlices,
|
|
1981
|
+
maxRetries,
|
|
1982
|
+
probeProfiles,
|
|
1983
|
+
agentBusyWaitSeconds,
|
|
1984
|
+
agentBusyMaxWaits
|
|
1985
|
+
});
|
|
1986
|
+
const payload = {
|
|
1987
|
+
status: "ok",
|
|
1988
|
+
dryRun: false,
|
|
1989
|
+
mode: "durable",
|
|
1990
|
+
jobId: resolvedJobId,
|
|
1991
|
+
resumed: createResult?.resumed ?? true,
|
|
1992
|
+
sourceQueryUrl: crawl.job.sourceQueryUrl,
|
|
1993
|
+
slicePreset: crawl.job.slicePreset,
|
|
1994
|
+
maxResultsPerSearch: crawl.job.maxResultsPerSearch,
|
|
1995
|
+
numberOfProfiles: crawl.job.numberOfProfiles,
|
|
1996
|
+
claimedSlices: crawl.claimedSlices,
|
|
1997
|
+
truncated: crawl.truncated,
|
|
1998
|
+
job: crawl.job,
|
|
1999
|
+
activeSlice: crawl.activeSlice
|
|
2000
|
+
? {
|
|
2001
|
+
id: crawl.activeSlice.id,
|
|
2002
|
+
slicedQueryUrl: crawl.activeSlice.slicedQueryUrl,
|
|
2003
|
+
depth: crawl.activeSlice.depth,
|
|
2004
|
+
splitTrail: formatSalesNavigatorSplitTrail(crawl.activeSlice.splitTrail),
|
|
2005
|
+
retryCount: crawl.activeSlice.retryCount,
|
|
2006
|
+
cookieRetryCount: crawl.activeSlice.cookieRetryCount,
|
|
2007
|
+
resultRetryCount: crawl.activeSlice.resultRetryCount
|
|
2008
|
+
}
|
|
2009
|
+
: null,
|
|
2010
|
+
lastOutcome: crawl.lastOutcome
|
|
2011
|
+
};
|
|
2012
|
+
if (options.out) {
|
|
2013
|
+
await writeJsonFile(options.out, payload);
|
|
2014
|
+
}
|
|
2015
|
+
printOutput(payload);
|
|
2016
|
+
});
|
|
2017
|
+
program
|
|
2018
|
+
.command("salesnav:crawl:status")
|
|
2019
|
+
.description("Return the current status of a durable Sales Navigator crawl job.")
|
|
2020
|
+
.requiredOption("--job-id <id>", "Sales Navigator crawl job id")
|
|
2021
|
+
.action(async (options) => {
|
|
2022
|
+
const jobId = z.string().uuid().parse(options.jobId);
|
|
2023
|
+
let session = await requireAuthSession();
|
|
2024
|
+
const status = await getSalesNavigatorCrawlStatus(session, jobId);
|
|
2025
|
+
session = status.session;
|
|
2026
|
+
void session;
|
|
2027
|
+
printOutput({
|
|
2028
|
+
status: "ok",
|
|
2029
|
+
jobId,
|
|
2030
|
+
job: status.value.job
|
|
2031
|
+
});
|
|
2032
|
+
});
|
|
2033
|
+
program
|
|
2034
|
+
.command("salesnav:export")
|
|
2035
|
+
.description("Apply the default Sales Navigator HR slice filters to one or more people-search URLs, then export and store the results through Salesprompter.")
|
|
2036
|
+
.requiredOption("--query-url <url>", "Base LinkedIn Sales Navigator people search URL", collectStringOptionValue, [])
|
|
2037
|
+
.option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
|
|
2038
|
+
.option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
|
|
2039
|
+
.option("--slice-preset <name>", "Slice preset label stored with the export run", "human-resources-default")
|
|
2040
|
+
.option("--out <path>", "Optional local JSON output path")
|
|
2041
|
+
.option("--dry-run", "Only generate sliced query URLs without exporting them", false)
|
|
2042
|
+
.action(async (options) => {
|
|
2043
|
+
const queryUrls = z.array(z.string().url()).min(1).parse(options.queryUrl);
|
|
2044
|
+
const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
|
|
2045
|
+
const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
|
|
2046
|
+
const prepared = queryUrls.map((queryUrl) => buildSalesNavigatorPeopleSlice(queryUrl));
|
|
2047
|
+
const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
|
|
2048
|
+
if (effectiveDryRun) {
|
|
2049
|
+
const payload = {
|
|
2050
|
+
status: "ok",
|
|
2051
|
+
dryRun: true,
|
|
2052
|
+
queries: prepared.map((item) => ({
|
|
2053
|
+
sourceQueryUrl: item.sourceQueryUrl,
|
|
2054
|
+
slicedQueryUrl: item.slicedQueryUrl,
|
|
2055
|
+
appliedFilters: item.appliedFilters,
|
|
2056
|
+
maxResultsPerSearch,
|
|
2057
|
+
numberOfProfiles,
|
|
2058
|
+
slicePreset: options.slicePreset
|
|
2059
|
+
}))
|
|
2060
|
+
};
|
|
2061
|
+
if (options.out) {
|
|
2062
|
+
await writeJsonFile(options.out, payload);
|
|
2063
|
+
}
|
|
2064
|
+
printOutput(payload);
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
const session = await requireAuthSession();
|
|
2068
|
+
const exported = [];
|
|
2069
|
+
for (const item of prepared) {
|
|
2070
|
+
const result = await runSalesNavigatorExport(session, {
|
|
2071
|
+
sourceQueryUrl: item.sourceQueryUrl,
|
|
2072
|
+
slicedQueryUrl: item.slicedQueryUrl,
|
|
2073
|
+
appliedFilters: item.appliedFilters,
|
|
2074
|
+
maxResultsPerSearch,
|
|
2075
|
+
numberOfProfiles,
|
|
2076
|
+
slicePreset: options.slicePreset
|
|
2077
|
+
});
|
|
2078
|
+
exported.push(result);
|
|
2079
|
+
}
|
|
2080
|
+
const payload = {
|
|
2081
|
+
status: "ok",
|
|
2082
|
+
dryRun: false,
|
|
2083
|
+
queries: exported
|
|
2084
|
+
};
|
|
2085
|
+
if (options.out) {
|
|
2086
|
+
await writeJsonFile(options.out, payload);
|
|
2087
|
+
}
|
|
2088
|
+
printOutput(payload);
|
|
2089
|
+
});
|
|
1263
2090
|
program
|
|
1264
2091
|
.command("leads:lookup:bq")
|
|
1265
2092
|
.description("Build a BigQuery lead lookup from an ICP and optionally execute it with the bq CLI.")
|
|
@@ -1361,6 +2188,53 @@ program
|
|
|
1361
2188
|
sourceTables: report.sourceTables
|
|
1362
2189
|
});
|
|
1363
2190
|
});
|
|
2191
|
+
program
|
|
2192
|
+
.command("leadlists:direct-export:bq")
|
|
2193
|
+
.description("Export upstream leads directly from leadLists -> linkedin_contacts -> linkedin_companies and segment them.")
|
|
2194
|
+
.requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
|
|
2195
|
+
.option("--market <market>", "global|europe|dach", "dach")
|
|
2196
|
+
.option("--limit <number>", "Max rows to export", "20000")
|
|
2197
|
+
.requiredOption("--out-dir <path>", "Output directory for raw and segmented files")
|
|
2198
|
+
.option("--sql-out <path>", "Optional file path for the generated SQL")
|
|
2199
|
+
.action(async (options) => {
|
|
2200
|
+
const vendor = z.enum(["deel"]).parse(options.vendor);
|
|
2201
|
+
const market = z.enum(["global", "europe", "dach"]).parse(options.market);
|
|
2202
|
+
const limit = z.coerce.number().int().min(1).max(100000).parse(options.limit);
|
|
2203
|
+
const sql = buildDirectPathLeadExportSql(vendor, market, limit);
|
|
2204
|
+
if (options.sqlOut) {
|
|
2205
|
+
await writeTextFile(options.sqlOut, `${sql}\n`);
|
|
2206
|
+
}
|
|
2207
|
+
const rows = await runBigQueryRows(sql, { maxRows: limit });
|
|
2208
|
+
const normalizedRows = normalizeDirectPathRows(rows);
|
|
2209
|
+
const pack = segmentDirectPathRows(vendor, market, normalizedRows);
|
|
2210
|
+
const baseSlug = `${slugify(vendor)}-direct-${market}`;
|
|
2211
|
+
const rawPath = path.join(options.outDir, `${baseSlug}-raw.json`);
|
|
2212
|
+
const packPath = path.join(options.outDir, `${baseSlug}-segments.json`);
|
|
2213
|
+
const leadershipPath = path.join(options.outDir, `${baseSlug}-leadership.json`);
|
|
2214
|
+
const payrollPath = path.join(options.outDir, `${baseSlug}-payroll-people-services.json`);
|
|
2215
|
+
const broaderPath = path.join(options.outDir, `${baseSlug}-broader-hr.json`);
|
|
2216
|
+
await writeJsonFile(rawPath, normalizedRows);
|
|
2217
|
+
await writeJsonFile(packPath, pack);
|
|
2218
|
+
await writeJsonFile(leadershipPath, pack.segments.leadership);
|
|
2219
|
+
await writeJsonFile(payrollPath, pack.segments["payroll-people-services"]);
|
|
2220
|
+
await writeJsonFile(broaderPath, pack.segments["broader-hr"]);
|
|
2221
|
+
printOutput({
|
|
2222
|
+
status: "ok",
|
|
2223
|
+
vendor,
|
|
2224
|
+
market,
|
|
2225
|
+
rowCount: normalizedRows.length,
|
|
2226
|
+
distinctContacts: pack.distinctContacts,
|
|
2227
|
+
distinctCompanies: pack.distinctCompanies,
|
|
2228
|
+
segmentCounts: pack.summary.segmentCounts,
|
|
2229
|
+
outDir: options.outDir,
|
|
2230
|
+
raw: rawPath,
|
|
2231
|
+
pack: packPath,
|
|
2232
|
+
leadership: leadershipPath,
|
|
2233
|
+
payrollPeopleServices: payrollPath,
|
|
2234
|
+
broaderHr: broaderPath,
|
|
2235
|
+
sqlOut: options.sqlOut ?? null
|
|
2236
|
+
});
|
|
2237
|
+
});
|
|
1364
2238
|
program
|
|
1365
2239
|
.command("leadlists:funnel:bq")
|
|
1366
2240
|
.description("Build an upstream lead-list funnel report for a vendor/market.")
|