salesprompter-cli 0.1.16 → 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/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,9 +338,82 @@ 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
  }
410
+ function writeWizardSection(title, description) {
411
+ writeWizardLine(title);
412
+ if (description) {
413
+ writeWizardLine(description);
414
+ }
415
+ writeWizardLine();
416
+ }
180
417
  function isOpaqueOrgId(value) {
181
418
  return /^org_[A-Za-z0-9]+$/.test(value);
182
419
  }
@@ -213,6 +450,9 @@ function buildLeadOutputPaths(baseSlug) {
213
450
  scoredPath: `./data/${baseSlug}-scored.json`
214
451
  };
215
452
  }
453
+ function buildQualifiedLeadsPath(baseSlug) {
454
+ return `./data/${baseSlug}-qualified-leads.json`;
455
+ }
216
456
  function normalizeChoiceText(value) {
217
457
  return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
218
458
  }
@@ -266,11 +506,8 @@ async function promptChoiceInteractive(prompt, options, defaultIndex) {
266
506
  if (!selected) {
267
507
  throw new Error("wizard selection invariant violated");
268
508
  }
269
- const summary = `${prompt} ${selected.label}`;
270
- redrawInteractiveChoice([summary], renderedLineCount);
271
- renderedLineCount = 1;
272
- process.stdout.write("\n");
273
509
  cleanup();
510
+ process.stdout.write("\n");
274
511
  resolve(selected.value);
275
512
  };
276
513
  const cancel = (reject) => {
@@ -402,44 +639,6 @@ async function promptYesNo(rl, prompt, defaultValue) {
402
639
  writeWizardLine("Please answer yes or no.");
403
640
  }
404
641
  }
405
- async function maybeSearchLeadDataNow(rl, options) {
406
- const shouldSearch = await promptYesNo(rl, "Do you want to search your lead data for matches now?", false);
407
- writeWizardLine();
408
- if (!shouldSearch) {
409
- return;
410
- }
411
- await runVendorLookupWizard(rl, { icpPath: options.icpPath });
412
- }
413
- async function maybePrepareLeadsForOutreach(rl, options) {
414
- const shouldScore = await promptYesNo(rl, "Do you want me to score these leads for outreach?", false);
415
- writeWizardLine();
416
- if (!shouldScore) {
417
- return;
418
- }
419
- const { enrichedPath, scoredPath } = buildLeadOutputPaths(options.baseSlug);
420
- const enriched = await enrichmentProvider.enrichLeads(options.leads);
421
- const scored = await scoringProvider.scoreLeads(options.icp, enriched);
422
- await writeJsonFile(enrichedPath, enriched);
423
- await writeJsonFile(scoredPath, scored);
424
- writeWizardLine(`Saved enriched leads to ${enrichedPath}.`);
425
- writeWizardLine(`Saved scored leads to ${scoredPath}.`);
426
- writeWizardLine();
427
- writeWizardLine("Equivalent raw commands:");
428
- writeWizardLine(` ${buildCommandLine(["salesprompter", "leads:enrich", "--in", options.leadPath, "--out", enrichedPath])}`);
429
- writeWizardLine(` ${buildCommandLine(["salesprompter", "leads:score", "--icp", options.icpPath, "--in", enrichedPath, "--out", scoredPath])}`);
430
- if (!process.env.INSTANTLY_API_KEY || process.env.INSTANTLY_API_KEY.trim().length === 0) {
431
- writeWizardLine();
432
- writeWizardLine("You can send the scored leads to Instantly later from the main menu.");
433
- return;
434
- }
435
- writeWizardLine();
436
- const shouldSync = await promptYesNo(rl, "Do you want to send these leads to Instantly now?", false);
437
- writeWizardLine();
438
- if (!shouldSync) {
439
- return;
440
- }
441
- await runOutreachSyncWizard(rl, { inputPath: scoredPath });
442
- }
443
642
  async function ensureWizardSession(options) {
444
643
  if (shouldBypassAuth()) {
445
644
  return null;
@@ -456,7 +655,7 @@ async function ensureWizardSession(options) {
456
655
  throw error;
457
656
  }
458
657
  }
459
- writeWizardLine("First, sign in to Salesprompter.");
658
+ writeWizardLine("First, sign in to continue.");
460
659
  writeWizardLine();
461
660
  const result = await performLogin({
462
661
  apiUrl: options?.apiUrl,
@@ -466,253 +665,617 @@ async function ensureWizardSession(options) {
466
665
  writeWizardLine();
467
666
  return result.session;
468
667
  }
469
- async function runVendorIcpWizard(rl) {
470
- const startPoint = await promptChoice(rl, "How do you want to build your ICP?", [
471
- {
472
- value: "custom",
473
- label: "Start from scratch",
474
- description: "Answer a few questions about the companies you want to sell to",
475
- 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}`
476
674
  },
477
- {
478
- value: "template",
479
- label: "Use the Deel template",
480
- description: "Quick start from the current built-in template",
481
- aliases: ["template", "deel", "use template"]
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
+ };
482
720
  }
483
- ], "custom");
484
- writeWizardLine();
485
- if (startPoint === "custom") {
486
- const productName = await promptText(rl, "What do you sell?", { required: true });
487
- const description = await promptText(rl, "Short description (optional)");
488
- const industries = await promptText(rl, "Industries to target (optional, comma-separated)");
489
- const companySizes = await promptText(rl, "Company sizes to target (optional, comma-separated)");
490
- const regions = await promptText(rl, "Regions to target (optional, comma-separated)");
491
- const countries = await promptText(rl, "Countries to target (optional, comma-separated)");
492
- const titles = await promptText(rl, "Job titles to target (optional, comma-separated)");
493
- const keywords = await promptText(rl, "Keywords or buying signals (optional, comma-separated)");
494
- writeWizardLine();
495
- const slug = slugify(productName) || "icp";
496
- const outPath = `./data/${slug}-icp.json`;
497
- const icp = IcpSchema.parse({
498
- name: `${productName} ICP`,
499
- description,
500
- industries: splitCsv(industries),
501
- companySizes: splitCsv(companySizes),
502
- regions: splitCsv(regions),
503
- countries: splitCsv(countries),
504
- titles: splitCsv(titles),
505
- keywords: splitCsv(keywords)
506
- });
507
- await writeJsonFile(outPath, icp);
508
- writeWizardLine(`Created ${icp.name}.`);
509
- writeWizardLine(`Saved profile to ${outPath}.`);
510
- writeWizardLine();
511
- writeWizardLine("Equivalent raw command:");
512
- const defineArgs = ["salesprompter", "icp:define", "--name", icp.name];
513
- if (description.trim().length > 0) {
514
- 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();
515
731
  }
516
- if (industries.trim().length > 0) {
517
- defineArgs.push("--industries", industries);
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);
518
747
  }
519
- if (companySizes.trim().length > 0) {
520
- defineArgs.push("--company-sizes", companySizes);
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);
521
774
  }
522
- if (regions.trim().length > 0) {
523
- defineArgs.push("--regions", regions);
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}`
524
814
  }
525
- if (countries.trim().length > 0) {
526
- defineArgs.push("--countries", countries);
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;
527
861
  }
528
- if (titles.trim().length > 0) {
529
- defineArgs.push("--titles", titles);
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);
530
909
  }
531
- if (keywords.trim().length > 0) {
532
- defineArgs.push("--keywords", keywords);
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;
533
935
  }
534
- defineArgs.push("--out", outPath);
535
- writeWizardLine(` ${buildCommandLine(defineArgs)}`);
536
- writeWizardLine();
537
- await maybeSearchLeadDataNow(rl, { icpPath: outPath });
538
- return;
539
936
  }
540
- const vendor = "deel";
541
- writeWizardLine("Using the built-in Deel ICP template.");
542
- writeWizardLine();
543
- const market = await promptChoice(rl, "Which market do you want to focus on?", [
544
- { value: "dach", label: "DACH", description: "Germany, Austria, Switzerland" },
545
- { value: "europe", label: "Europe" },
546
- { value: "global", label: "Global" }
547
- ], "dach");
548
- writeWizardLine();
549
- const outPath = `./data/${slugify(vendor)}-icp-${market}.json`;
550
- const icp = buildVendorIcp(vendor, market);
551
- await writeJsonFile(outPath, icp);
552
- writeWizardLine(`Created ${icp.name}.`);
553
- writeWizardLine(`Saved profile to ${outPath}.`);
554
- writeWizardLine();
555
- writeWizardLine("Equivalent raw command:");
556
- writeWizardLine(` ${buildCommandLine(["salesprompter", "icp:vendor", "--vendor", vendor, "--market", market, "--out", outPath])}`);
557
- writeWizardLine();
558
- await maybeSearchLeadDataNow(rl, { icpPath: outPath });
559
937
  }
560
- async function runTargetAccountWizard(rl) {
561
- const domain = normalizeDomainInput(await promptText(rl, "Which company do you want leads from? Enter the domain", { required: true }));
562
- writeWizardLine();
563
- const companyName = await promptText(rl, "Company name (optional)");
564
- const displayName = companyName || deriveCompanyNameFromDomain(domain);
565
- const leadCount = z.coerce.number().int().min(1).max(1000).parse(await promptText(rl, "How many people do you want?", { defaultValue: "5", required: true }));
566
- const region = await promptText(rl, "Region", { defaultValue: "Global", required: true });
567
- const industries = await promptText(rl, "Industries (optional, comma-separated)");
568
- const titles = await promptText(rl, "Job titles (optional, comma-separated)");
569
- writeWizardLine();
570
- const slug = slugify(domain);
571
- const icpPath = `./data/${slug}-target-icp.json`;
572
- const { leadsPath } = buildLeadOutputPaths(slug);
573
- const icp = IcpSchema.parse({
574
- name: `${displayName} target account`,
575
- regions: region.length > 0 ? [region] : [],
576
- industries: splitCsv(industries),
577
- 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
578
955
  });
579
- await writeJsonFile(icpPath, icp);
580
- const result = await leadProvider.generateLeads(icp, leadCount, {
581
- companyDomain: domain,
582
- companyName: companyName || undefined
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
583
975
  });
584
- await writeJsonFile(leadsPath, result.leads);
585
- writeWizardLine(`Generated ${result.leads.length} leads for ${result.account.companyName} (${result.account.domain}).`);
586
- writeWizardLine(`Saved profile to ${icpPath}.`);
587
- writeWizardLine(`Saved leads to ${leadsPath}.`);
588
- if (result.warnings.length > 0) {
589
- writeWizardLine();
590
- writeWizardLine(`Warning: ${result.warnings.join(" ")}`);
591
- }
592
- writeWizardLine();
593
- writeWizardLine("Equivalent raw commands:");
594
- const defineArgs = ["salesprompter", "icp:define", "--name", icp.name];
595
- if (region.length > 0) {
596
- defineArgs.push("--regions", region);
976
+ }
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}`
996
+ },
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}`
1011
+ }
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;
597
1041
  }
598
- if (industries.trim().length > 0) {
599
- defineArgs.push("--industries", industries);
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
+ }));
1054
+ }
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
+ };
600
1077
  }
601
- if (titles.trim().length > 0) {
602
- defineArgs.push("--titles", titles);
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
+ };
603
1090
  }
604
- defineArgs.push("--out", icpPath);
605
- writeWizardLine(` ${buildCommandLine(defineArgs)}`);
606
- const leadArgs = ["salesprompter", "leads:generate", "--icp", icpPath, "--count", String(leadCount), "--domain", domain];
607
- if (companyName.trim().length > 0) {
608
- leadArgs.push("--company-name", companyName);
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
+ };
609
1103
  }
610
- leadArgs.push("--out", leadsPath);
611
- writeWizardLine(` ${buildCommandLine(leadArgs)}`);
612
- writeWizardLine();
613
- await maybePrepareLeadsForOutreach(rl, {
614
- baseSlug: slug,
615
- icp,
616
- icpPath,
617
- leadPath: leadsPath,
618
- leads: result.leads
619
- });
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
+ };
620
1113
  }
621
- async function runLeadGenerationWizard(rl) {
622
- const source = await promptChoice(rl, "How do you want to generate leads?", [
623
- {
624
- value: "target-account",
625
- label: "At one company",
626
- description: "Example: find people at acme.com",
627
- aliases: ["company", "one company", "specific company", "account"]
628
- },
629
- {
630
- value: "vendor-lookup",
631
- label: "From my own lead data",
632
- description: "Search leads you already have in BigQuery",
633
- aliases: ["bigquery", "warehouse", "my data", "from bigquery"]
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
+ };
634
1177
  }
635
- ], "target-account");
636
- writeWizardLine();
637
- if (source === "target-account") {
638
- await runTargetAccountWizard(rl);
639
- return;
640
1178
  }
641
- await runVendorLookupWizard(rl);
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
+ };
642
1192
  }
643
- async function runVendorLookupWizard(rl, options) {
644
- const icpPath = options?.icpPath
645
- ? options.icpPath
646
- : await promptText(rl, "Which saved ICP should I use?", {
647
- defaultValue: "./data/icp.json",
648
- required: true
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
649
1199
  });
650
- if (options?.icpPath) {
651
- writeWizardLine(`Using profile from ${icpPath}.`);
1200
+ return result.leads;
652
1201
  }
653
- const limit = z.coerce.number().int().min(1).max(5000).parse(await promptText(rl, "How many leads do you want?", { defaultValue: "100", required: true }));
654
- const execute = await promptYesNo(rl, "Do you want me to run the BigQuery search now?", false);
655
- writeWizardLine();
656
- const icp = await readJsonFile(icpPath, IcpSchema);
657
- const slug = slugify(icp.name) || "icp";
658
- const sqlPath = `./data/${slug}-lookup.sql`;
659
- const rawPath = `./data/${slug}-leads-raw.json`;
660
- const { leadsPath: leadPath } = buildLeadOutputPaths(slug);
661
- const sql = buildBigQueryLeadLookupSql(icp, {
662
- table: "icpidentifier.SalesGPT.leadPool_new",
663
- companyField: "companyName",
664
- domainField: "domain",
665
- regionField: undefined,
666
- keywordFields: splitCsv("companyName,industry,description,tagline,specialties"),
667
- titleField: "jobTitle",
668
- industryField: "industry",
669
- companySizeField: "companySize",
670
- countryField: "company_countryCode",
671
- firstNameField: "firstName",
672
- lastNameField: "lastName",
673
- emailField: "email",
674
- limit,
675
- additionalWhere: undefined,
676
- useSalesprompterGuards: true
1202
+ const session = await requireAuthSession();
1203
+ return await fetchWorkspaceLeadSearch(session, {
1204
+ mode: "reference-company",
1205
+ icp,
1206
+ limit
677
1207
  });
678
- await writeTextFile(sqlPath, `${sql}\n`);
679
- let executedRowCount = null;
680
- let normalizedLeads = [];
681
- if (execute) {
682
- const rows = await runBigQueryQuery(sql, { maxRows: limit });
683
- const parsedRows = z.array(z.record(z.string(), z.unknown())).parse(rows);
684
- await writeJsonFile(rawPath, parsedRows);
685
- normalizedLeads = normalizeBigQueryLeadRows(parsedRows);
686
- await writeJsonFile(leadPath, normalizedLeads);
687
- executedRowCount = parsedRows.length;
688
- }
689
- writeWizardLine(`Saved search SQL to ${sqlPath}.`);
690
- if (execute) {
691
- writeWizardLine(`Saved ${executedRowCount ?? 0} raw rows to ${rawPath}.`);
692
- 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;
693
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
+ }));
694
1231
  writeWizardLine();
695
- writeWizardLine("Equivalent raw command:");
696
- const lookupArgs = ["salesprompter", "leads:lookup:bq", "--icp", icpPath, "--limit", String(limit), "--sql-out", sqlPath];
697
- if (execute) {
698
- 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.");
699
1234
  }
700
- writeWizardLine(` ${buildCommandLine(lookupArgs)}`);
701
- if (!execute) {
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.`);
702
1252
  return;
703
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
+ }));
704
1262
  writeWizardLine();
705
- await maybePrepareLeadsForOutreach(rl, {
706
- baseSlug: slug,
707
- icp,
708
- icpPath,
709
- leadPath,
710
- leads: normalizedLeads
711
- });
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}.`);
712
1274
  }
713
1275
  async function runOutreachSyncWizard(rl, options) {
714
1276
  const target = "instantly";
715
1277
  const targetLabel = "Instantly";
1278
+ writeWizardSection("Send to Instantly", options?.inputPath ? "I already have the scored leads ready." : "Use a scored leads file to fill a campaign.");
716
1279
  writeWizardLine(`Using ${targetLabel}.`);
717
1280
  writeWizardLine();
718
1281
  if (!process.env.INSTANTLY_API_KEY || process.env.INSTANTLY_API_KEY.trim().length === 0) {
@@ -779,7 +1342,7 @@ async function runWizard(options) {
779
1342
  throw new Error("wizard does not support --json or --quiet.");
780
1343
  }
781
1344
  writeWizardLine("Salesprompter");
782
- writeWizardLine("Tell me what you want to do, and I will guide you through it.");
1345
+ writeWizardLine("Start with a company website or LinkedIn page. I will guide you from there.");
783
1346
  writeWizardLine();
784
1347
  await ensureWizardSession(options);
785
1348
  const rl = createInterface({
@@ -789,31 +1352,31 @@ async function runWizard(options) {
789
1352
  try {
790
1353
  const flow = await promptChoice(rl, "What do you want help with?", [
791
1354
  {
792
- value: "vendor-icp",
793
- label: "Define my ICP",
794
- description: "Build the company profile you want to sell to",
795
- aliases: ["icp", "ideal customer", "who to target", "targeting", "profile"]
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"]
796
1359
  },
797
1360
  {
798
- value: "lead-generation",
799
- label: "Generate leads",
800
- description: "Find people at one company or from your own lead data",
801
- aliases: ["leads", "find leads", "lead generation", "find people"]
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"]
802
1365
  },
803
1366
  {
804
1367
  value: "outreach-sync",
805
- label: "Send leads to Instantly",
806
- description: "Use a scored leads file to fill an Instantly campaign",
1368
+ label: "Push qualified leads to Instantly",
1369
+ description: "Use a saved leads file to fill an Instantly campaign",
807
1370
  aliases: ["instantly", "outreach", "send leads", "campaign"]
808
1371
  }
809
- ], "vendor-icp");
1372
+ ], "reference-company");
810
1373
  writeWizardLine();
811
- if (flow === "vendor-icp") {
812
- await runVendorIcpWizard(rl);
1374
+ if (flow === "reference-company") {
1375
+ await runReferenceCompanyWizard(rl);
813
1376
  return;
814
1377
  }
815
- if (flow === "lead-generation") {
816
- await runLeadGenerationWizard(rl);
1378
+ if (flow === "target-company") {
1379
+ await runTargetCompanyWizard(rl);
817
1380
  return;
818
1381
  }
819
1382
  await runOutreachSyncWizard(rl);
@@ -1027,12 +1590,34 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
1027
1590
  if (commandName.startsWith("auth:") || commandName === "wizard") {
1028
1591
  return;
1029
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
+ }
1030
1600
  if (shouldBypassAuth()) {
1031
1601
  return;
1032
1602
  }
1033
- const session = await requireAuthSession();
1034
- if (session.expiresAt !== undefined && Date.now() >= Date.parse(session.expiresAt)) {
1035
- throw new Error("session expired. Run `salesprompter auth:login`.");
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;
1036
1621
  }
1037
1622
  });
1038
1623
  program
@@ -1230,6 +1815,278 @@ program
1230
1815
  });
1231
1816
  printOutput({ status: "ok", ...result });
1232
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
+ });
1233
2090
  program
1234
2091
  .command("leads:lookup:bq")
1235
2092
  .description("Build a BigQuery lead lookup from an ICP and optionally execute it with the bq CLI.")
@@ -1331,6 +2188,53 @@ program
1331
2188
  sourceTables: report.sourceTables
1332
2189
  });
1333
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
+ });
1334
2238
  program
1335
2239
  .command("leadlists:funnel:bq")
1336
2240
  .description("Build an upstream lead-list funnel report for a vendor/market.")