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/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 Salesprompter.");
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 runVendorIcpWizard(rl) {
479
- const startPoint = await promptChoice(rl, "How do you want to build your ICP?", [
480
- {
481
- value: "custom",
482
- label: "Start from scratch",
483
- description: "Answer a few questions about the companies you want to sell to",
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
- value: "template",
488
- label: "Use the Deel template",
489
- description: "Quick start from the current built-in template",
490
- aliases: ["template", "deel", "use template"]
491
- }
492
- ], "custom");
493
- writeWizardLine();
494
- if (startPoint === "custom") {
495
- writeWizardSection("Define your ICP", "Start with the basics. You can add more filters if you want.");
496
- const productName = await promptText(rl, "What do you sell?", { required: true });
497
- const description = await promptText(rl, "Short description (optional)");
498
- writeWizardLine();
499
- let industries = "";
500
- let companySizes = "";
501
- let regions = "";
502
- let countries = "";
503
- let titles = "";
504
- let keywords = "";
505
- const addDetails = await promptYesNo(rl, "Do you want to add industries, company size, region, or title filters?", false);
506
- writeWizardLine();
507
- if (addDetails) {
508
- industries = await promptText(rl, "Industries to target (optional, comma-separated)");
509
- companySizes = await promptText(rl, "Company sizes to target (optional, comma-separated)");
510
- regions = await promptText(rl, "Regions to target (optional, comma-separated)");
511
- countries = await promptText(rl, "Countries to target (optional, comma-separated)");
512
- titles = await promptText(rl, "Job titles to target (optional, comma-separated)");
513
- keywords = await promptText(rl, "Keywords or buying signals (optional, comma-separated)");
514
- writeWizardLine();
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
- const slug = slugify(productName) || "icp";
517
- const outPath = `./data/${slug}-icp.json`;
518
- const icp = IcpSchema.parse({
519
- name: `${productName} ICP`,
520
- description,
521
- industries: splitCsv(industries),
522
- companySizes: splitCsv(companySizes),
523
- regions: splitCsv(regions),
524
- countries: splitCsv(countries),
525
- titles: splitCsv(titles),
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
- if (industries.trim().length > 0) {
538
- 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);
539
747
  }
540
- if (companySizes.trim().length > 0) {
541
- 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);
542
774
  }
543
- if (regions.trim().length > 0) {
544
- 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}`
545
814
  }
546
- if (countries.trim().length > 0) {
547
- 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;
548
861
  }
549
- if (titles.trim().length > 0) {
550
- 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);
551
909
  }
552
- if (keywords.trim().length > 0) {
553
- 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;
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 runTargetAccountWizard(rl) {
583
- writeWizardSection("Pick a company", "Start with the company and how many people you want.");
584
- const domain = normalizeDomainInput(await promptText(rl, "Which company do you want leads from? Enter the domain", { required: true }));
585
- writeWizardLine();
586
- const displayName = deriveCompanyNameFromDomain(domain);
587
- const leadCount = z.coerce.number().int().min(1).max(1000).parse(await promptText(rl, "How many people do you want?", { defaultValue: "5", required: true }));
588
- writeWizardLine();
589
- let region = "Global";
590
- let industries = "";
591
- let titles = "";
592
- const narrowResults = await promptYesNo(rl, "Do you want to narrow the results by region, industry, or title?", false);
593
- writeWizardLine();
594
- if (narrowResults) {
595
- region = await promptText(rl, "Region", { defaultValue: "Global", required: true });
596
- industries = await promptText(rl, "Industries (optional, comma-separated)");
597
- titles = await promptText(rl, "Job titles (optional, comma-separated)");
598
- writeWizardLine();
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
- await writeJsonFile(icpPath, icp);
610
- const result = await leadProvider.generateLeads(icp, leadCount, {
611
- companyDomain: domain
612
- });
613
- await writeJsonFile(leadsPath, result.leads);
614
- writeWizardLine(`Generated ${result.leads.length} leads for ${result.account.companyName} (${result.account.domain}).`);
615
- writeWizardLine(`Saved profile to ${icpPath}.`);
616
- writeWizardLine(`Saved leads to ${leadsPath}.`);
617
- if (result.warnings.length > 0) {
618
- writeWizardLine();
619
- writeWizardLine(`Warning: ${result.warnings.join(" ")}`);
620
- }
621
- writeWizardLine();
622
- writeWizardLine("Equivalent raw commands:");
623
- const defineArgs = ["salesprompter", "icp:define", "--name", icp.name];
624
- if (region.length > 0) {
625
- defineArgs.push("--regions", region);
626
- }
627
- if (industries.trim().length > 0) {
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
- async function runLeadGenerationWizard(rl) {
648
- const source = await promptChoice(rl, "How do you want to generate leads?", [
649
- {
650
- value: "target-account",
651
- label: "At one company",
652
- description: "Example: find people at acme.com",
653
- aliases: ["company", "one company", "specific company", "account"]
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
- value: "vendor-lookup",
657
- label: "From my own lead data",
658
- description: "Search leads you already have in BigQuery",
659
- aliases: ["bigquery", "warehouse", "my data", "from bigquery"]
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
- ], "target-account");
662
- writeWizardLine();
663
- if (source === "target-account") {
664
- await runTargetAccountWizard(rl);
665
- return;
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
- await runVendorLookupWizard(rl);
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
- async function runVendorLookupWizard(rl, options) {
670
- if (!options?.icpPath) {
671
- writeWizardSection("Search your lead data", "Use a saved ICP to build the lookup.");
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
- const icpPath = options?.icpPath
674
- ? options.icpPath
675
- : await promptText(rl, "Which saved ICP should I use?", {
676
- defaultValue: "./data/icp.json",
677
- required: true
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
- if (options?.icpPath) {
680
- writeWizardLine(`Using profile from ${icpPath}.`);
1200
+ return result.leads;
681
1201
  }
682
- const limit = z.coerce.number().int().min(1).max(5000).parse(await promptText(rl, "How many leads do you want?", { defaultValue: "100", required: true }));
683
- const execute = await promptYesNo(rl, "Do you want me to run the BigQuery search now?", false);
684
- writeWizardLine();
685
- const icp = await readJsonFile(icpPath, IcpSchema);
686
- const slug = slugify(icp.name) || "icp";
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
- await writeTextFile(sqlPath, `${sql}\n`);
708
- let executedRowCount = null;
709
- let normalizedLeads = [];
710
- if (execute) {
711
- const rows = await runBigQueryQuery(sql, { maxRows: limit });
712
- const parsedRows = z.array(z.record(z.string(), z.unknown())).parse(rows);
713
- await writeJsonFile(rawPath, parsedRows);
714
- normalizedLeads = normalizeBigQueryLeadRows(parsedRows);
715
- await writeJsonFile(leadPath, normalizedLeads);
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
- writeWizardLine("Equivalent raw command:");
725
- const lookupArgs = ["salesprompter", "leads:lookup:bq", "--icp", icpPath, "--limit", String(limit), "--sql-out", sqlPath];
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
- writeWizardLine(` ${buildCommandLine(lookupArgs)}`);
730
- 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.`);
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 maybePrepareLeadsForOutreach(rl, {
735
- baseSlug: slug,
736
- icp,
737
- icpPath,
738
- leadPath,
739
- leads: normalizedLeads
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("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.");
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: "vendor-icp",
823
- label: "Define my ICP",
824
- description: "Build the company profile you want to sell to",
825
- 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"]
826
1359
  },
827
1360
  {
828
- value: "lead-generation",
829
- label: "Generate leads",
830
- description: "Find people at one company or from your own lead data",
831
- 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"]
832
1365
  },
833
1366
  {
834
1367
  value: "outreach-sync",
835
- label: "Send leads to Instantly",
836
- 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",
837
1370
  aliases: ["instantly", "outreach", "send leads", "campaign"]
838
1371
  }
839
- ], "vendor-icp");
1372
+ ], "reference-company");
840
1373
  writeWizardLine();
841
- if (flow === "vendor-icp") {
842
- await runVendorIcpWizard(rl);
1374
+ if (flow === "reference-company") {
1375
+ await runReferenceCompanyWizard(rl);
843
1376
  return;
844
1377
  }
845
- if (flow === "lead-generation") {
846
- await runLeadGenerationWizard(rl);
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
- const session = await requireAuthSession();
1064
- if (session.expiresAt !== undefined && Date.now() >= Date.parse(session.expiresAt)) {
1065
- 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;
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.")