salesprompter-cli 0.1.46 → 0.1.48

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.
Files changed (3) hide show
  1. package/README.md +6 -2
  2. package/dist/cli.js +149 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -65,7 +65,11 @@ salesprompter companies:resolve-linkedin-urls --in ./companies.txt --out ./compa
65
65
  # When authenticated, Salesprompter first enriches company domains in the app, then the CLI builds the email batch.
66
66
  salesprompter contacts:resolve-emails --in ./contacts.tsv --out-dir ./email-run --dry-run
67
67
 
68
- # Preview an affiliate audience without touching LinkedIn
68
+ # Collect a Sales Navigator people search into the active workspace
69
+ salesprompter leads:collect \
70
+ --linkedin-url "$SALES_NAV_PEOPLE_URL" \
71
+ --max-results 1000
72
+
69
73
  salesprompter affiliate:launch \
70
74
  --affiliate-link "https://example.com/?ref=you" \
71
75
  --linkedin-url "https://www.linkedin.com/sales/search/people?query=..." \
@@ -110,7 +114,7 @@ salesprompter --help
110
114
  - Respect provider terms and customer data boundaries.
111
115
  - `affiliate:run` uses direct email enrichment first, then Phantombuster Email Finder for unresolved people before creating a draft Instantly campaign.
112
116
  - Affiliate preparation automatically creates three variants per step with spintax; `affiliate:regenerate-sequence` rebuilds and syncs them through the Salesprompter app.
113
- - `affiliate:enrich` reruns email recovery and adds only newly found addresses to an existing campaign. Pass `--source-audience-run-id` to merge a newer compatible audience into that campaign without creating a duplicate campaign.
117
+ - `affiliate:enrich` starts durable email recovery and adds only newly found addresses to an existing campaign. It returns a background-processing status for large audiences instead of blocking the terminal. Pass `--source-audience-run-id` to merge a newer compatible audience into that campaign without creating a duplicate campaign.
114
118
  - A draft campaign does not send until `affiliate:activate` is run.
115
119
  - The CLI is designed for interactive users and agent-assisted workflows.
116
120
  - A repo-local Chrome extension compatibility copy is available in `chrome-extension/` for local LinkedIn session sync and popup copy/debug flows.
package/dist/cli.js CHANGED
@@ -147,6 +147,14 @@ const AffiliateCampaignLaunchResponseSchema = z.object({
147
147
  })
148
148
  .passthrough()
149
149
  .optional(),
150
+ workspaceImport: z
151
+ .object({
152
+ runId: z.string().uuid(),
153
+ imported: z.number().int().nonnegative(),
154
+ upserted: z.number().int().nonnegative(),
155
+ previewUrl: z.string().min(1)
156
+ })
157
+ .optional(),
150
158
  previewUrl: z.string().nullable().optional(),
151
159
  next: z.string().optional()
152
160
  });
@@ -469,6 +477,7 @@ const cliPacks = [
469
477
  summary: "Find leads from product and market inputs.",
470
478
  commands: [
471
479
  "leads:discover",
480
+ "leads:collect",
472
481
  "search:run",
473
482
  "search:status",
474
483
  "search:export",
@@ -506,6 +515,7 @@ const helpAliasByCommandName = new Map([
506
515
  ["dealroom-companies:scrape-local", "companies:scrape-dealroom"],
507
516
  ["linkedin-products:scrape", "market:scrape"],
508
517
  ["salesnav:from-product-category", "leads:discover"],
518
+ ["salesnav:people:collect", "leads:collect"],
509
519
  ["salesnav:crawl", "search:run"],
510
520
  ["salesnav:crawl:status", "search:status"],
511
521
  ["salesnav:export", "search:export"],
@@ -550,6 +560,7 @@ const helpVisibleCommandNames = new Set([
550
560
  "dealroom-companies:scrape-local",
551
561
  "linkedin-products:scrape",
552
562
  "salesnav:from-product-category",
563
+ "salesnav:people:collect",
553
564
  "salesnav:crawl",
554
565
  "salesnav:crawl:status",
555
566
  "salesnav:export",
@@ -6591,7 +6602,7 @@ async function enrichAffiliateOutreachViaApp(session, audienceRunId, sourceAudie
6591
6602
  const previousAttemptedAt = typeof before.stats.latestTopUp === "object" && before.stats.latestTopUp !== null
6592
6603
  ? String(before.stats.latestTopUp.attemptedAt ?? "")
6593
6604
  : "";
6594
- await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/affiliate-outreach/${encodeURIComponent(audienceRunId)}/enrich`, {
6605
+ const { value: started } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/affiliate-outreach/${encodeURIComponent(audienceRunId)}/enrich`, {
6595
6606
  method: "POST",
6596
6607
  headers: {
6597
6608
  "Content-Type": "application/json",
@@ -6599,7 +6610,11 @@ async function enrichAffiliateOutreachViaApp(session, audienceRunId, sourceAudie
6599
6610
  },
6600
6611
  body: JSON.stringify({ sourceAudienceRunId })
6601
6612
  }), AffiliateOutreachEnrichStartResponseSchema);
6602
- const deadline = Date.now() + 10 * 60 * 1000;
6613
+ const configuredWaitMs = Number(process.env.SALESPROMPTER_AFFILIATE_ENRICH_WAIT_MS);
6614
+ const waitMs = Number.isFinite(configuredWaitMs) && configuredWaitMs >= 0
6615
+ ? Math.min(10 * 60 * 1000, Math.trunc(configuredWaitMs))
6616
+ : 15_000;
6617
+ const deadline = Date.now() + waitMs;
6603
6618
  const configuredPollMs = Number(process.env.SALESPROMPTER_AFFILIATE_ENRICH_POLL_MS);
6604
6619
  const pollMs = Number.isFinite(configuredPollMs) && configuredPollMs > 0
6605
6620
  ? Math.max(10, Math.trunc(configuredPollMs))
@@ -6612,11 +6627,20 @@ async function enrichAffiliateOutreachViaApp(session, audienceRunId, sourceAudie
6612
6627
  ? current.stats.latestTopUp
6613
6628
  : null;
6614
6629
  const attemptedAt = String(latestTopUp?.attemptedAt ?? "");
6615
- if (attemptedAt && attemptedAt !== previousAttemptedAt) {
6616
- return current;
6630
+ const complete = latestTopUp?.complete !== false;
6631
+ if (attemptedAt && attemptedAt !== previousAttemptedAt && complete) {
6632
+ return {
6633
+ outreach: current,
6634
+ processing: false,
6635
+ workflowRunId: started.workflowRunId
6636
+ };
6617
6637
  }
6618
6638
  }
6619
- throw new Error("Email recovery is still running after 10 minutes. Check affiliate:status and retry later.");
6639
+ return {
6640
+ outreach: before,
6641
+ processing: true,
6642
+ workflowRunId: started.workflowRunId
6643
+ };
6620
6644
  }
6621
6645
  async function uploadLinkedInProductsCatalog(session, payload, batchSize = 100, traceId) {
6622
6646
  let imported = 0;
@@ -6780,6 +6804,10 @@ function buildSalesNavigatorApiRequestFromSearchUrl(searchUrl, config, count) {
6780
6804
  }
6781
6805
  function buildSalesNavigatorLeadApiUrlFromSearchUrl(searchUrl, count) {
6782
6806
  const source = new URL(searchUrl);
6807
+ if (!source.searchParams.has("query") && source.hash.startsWith("#query=")) {
6808
+ source.search = `?${source.hash.slice(1)}`;
6809
+ source.hash = "";
6810
+ }
6783
6811
  if (source.pathname.includes("/sales-api/salesApiLeadSearch")) {
6784
6812
  return source.toString();
6785
6813
  }
@@ -13185,6 +13213,99 @@ program
13185
13213
  }
13186
13214
  });
13187
13215
  });
13216
+ async function runSalesNavigatorPeopleCollectCommand(options) {
13217
+ const linkedInUrl = z.string().url().parse(options.linkedinUrl);
13218
+ const maxResults = z.coerce.number().int().min(1).max(100000).parse(options.maxResults);
13219
+ const pageSize = z.coerce.number().int().min(1).max(100).parse(options.pageSize);
13220
+ const normalizedSourceUrl = (() => {
13221
+ const source = new URL(linkedInUrl);
13222
+ if (!source.searchParams.has("query") && source.hash.startsWith("#query=")) {
13223
+ source.search = `?${source.hash.slice(1)}`;
13224
+ source.hash = "";
13225
+ }
13226
+ return source.toString();
13227
+ })();
13228
+ if (options.dryRun) {
13229
+ const payload = {
13230
+ status: "ok",
13231
+ dryRun: true,
13232
+ linkedInUrl: normalizedSourceUrl,
13233
+ maxResults,
13234
+ destination: "/leads/cli-imports"
13235
+ };
13236
+ if (options.out)
13237
+ await writeJsonFile(options.out, payload);
13238
+ return payload;
13239
+ }
13240
+ const session = await requireAuthSession();
13241
+ const pageDelayMinMs = z.coerce.number().int().min(0).parse(options.pageDelayMinMs);
13242
+ const pageDelayMaxMs = z.coerce.number().int().min(pageDelayMinMs).parse(options.pageDelayMaxMs);
13243
+ const browserRelayPort = options.browserRelayPort == null
13244
+ ? null
13245
+ : z.coerce.number().int().min(0).max(65535).parse(options.browserRelayPort);
13246
+ if (options.curlFile && browserRelayPort != null) {
13247
+ throw new Error("Use either --curl-file or --browser-relay-port, not both.");
13248
+ }
13249
+ const parsedRequest = options.curlFile
13250
+ ? parseSalesNavigatorCurlRequest(await readFile(path.resolve(String(options.curlFile)), "utf8"))
13251
+ : browserRelayPort != null
13252
+ ? {
13253
+ url: buildSalesNavigatorLeadApiUrlFromSearchUrl(normalizedSourceUrl, pageSize),
13254
+ headers: {}
13255
+ }
13256
+ : buildSalesNavigatorApiRequestFromSearchUrl(normalizedSourceUrl, await readLinkedInDirectLookupConfig(), pageSize);
13257
+ if (!new URL(parsedRequest.url).pathname.includes("/sales-api/salesApiLeadSearch")) {
13258
+ throw new Error("--curl-file must contain a /sales-api/salesApiLeadSearch request.");
13259
+ }
13260
+ const browserRelay = browserRelayPort == null
13261
+ ? null
13262
+ : await createLocalAccountSearchBrowserRelay(browserRelayPort);
13263
+ let collected;
13264
+ try {
13265
+ collected = await fetchAllLocalSalesNavigatorPeople(parsedRequest, {
13266
+ requestedProfiles: maxResults,
13267
+ pageSize,
13268
+ pageDelayMinMs,
13269
+ pageDelayMaxMs,
13270
+ retry: {
13271
+ maxRetries: 2,
13272
+ retryBaseDelayMs: 2_000,
13273
+ retryMaxDelayMs: 10_000
13274
+ },
13275
+ executeRequest: browserRelay
13276
+ ? (request) => browserRelay.request(request.url)
13277
+ : undefined
13278
+ });
13279
+ }
13280
+ finally {
13281
+ await browserRelay?.close();
13282
+ }
13283
+ const imported = await importLocalSalesNavigatorPeopleViaApp(session, {
13284
+ sourceQueryUrl: normalizedSourceUrl,
13285
+ people: collected.people,
13286
+ totalResults: collected.totalResults,
13287
+ fetchedPages: collected.fetchedPages,
13288
+ pacing: collected.pacing,
13289
+ maxResultsPerSearch: maxResults,
13290
+ numberOfProfiles: maxResults,
13291
+ slicePreset: "local-salesnav-people"
13292
+ });
13293
+ const payload = {
13294
+ status: "ok",
13295
+ dryRun: false,
13296
+ collected: collected.people.length,
13297
+ totalResults: collected.totalResults,
13298
+ fetchedPages: collected.fetchedPages,
13299
+ runId: imported.runId,
13300
+ imported: imported.imported,
13301
+ upserted: imported.upserted,
13302
+ workspaceUrl: `${session.apiBaseUrl}/leads/cli-imports?runId=` +
13303
+ encodeURIComponent(imported.runId)
13304
+ };
13305
+ if (options.out)
13306
+ await writeJsonFile(options.out, payload);
13307
+ return payload;
13308
+ }
13188
13309
  async function runAffiliateLaunchCommand(options) {
13189
13310
  const affiliateLink = z.string().url().parse(options.affiliateLink);
13190
13311
  const linkedInUrl = z.string().url().parse(options.linkedinUrl);
@@ -13270,6 +13391,22 @@ function addAffiliateAudienceOptions(command) {
13270
13391
  .option("--dry-run", "Validate and preview without creating remote resources", false)
13271
13392
  .option("--out <path>", "Optional local JSON output path");
13272
13393
  }
13394
+ program
13395
+ .command("salesnav:people:collect")
13396
+ .alias("leads:collect")
13397
+ .description("Collect a Sales Navigator people search into the active workspace.")
13398
+ .requiredOption("--linkedin-url <url>", "Sales Navigator people search URL")
13399
+ .option("--max-results <number>", "Maximum people to collect", "1000")
13400
+ .option("--curl-file <path>", "Optional copied Sales Navigator Lead Search curl request")
13401
+ .option("--browser-relay-port <number>", "Use the signed-in browser through a loopback relay")
13402
+ .option("--page-size <number>", "Direct Sales Navigator page size", "100")
13403
+ .option("--page-delay-min-ms <number>", "Minimum delay between direct pages", "5000")
13404
+ .option("--page-delay-max-ms <number>", "Maximum delay between direct pages", "8000")
13405
+ .option("--dry-run", "Validate without collecting or importing", false)
13406
+ .option("--out <path>", "Optional local JSON output path")
13407
+ .action(async (options) => {
13408
+ printOutput(await runSalesNavigatorPeopleCollectCommand(options));
13409
+ });
13273
13410
  addAffiliateAudienceOptions(program
13274
13411
  .command("affiliate:launch")
13275
13412
  .description("Build an affiliate audience from an affiliate link and LinkedIn search URL."))
@@ -13360,13 +13497,17 @@ program
13360
13497
  .option("--source-audience-run-id <run-id>", "Use a newer compatible audience to top up this campaign")
13361
13498
  .action(async (runId, options) => {
13362
13499
  const session = await requireAuthSession();
13363
- const outreach = await enrichAffiliateOutreachViaApp(session, z.string().uuid().parse(runId), options.sourceAudienceRunId
13500
+ const result = await enrichAffiliateOutreachViaApp(session, z.string().uuid().parse(runId), options.sourceAudienceRunId
13364
13501
  ? z.string().uuid().parse(options.sourceAudienceRunId)
13365
13502
  : undefined);
13366
13503
  printOutput({
13367
13504
  status: "ok",
13368
- outreach,
13369
- message: "Email recovery finished and new verified leads were added."
13505
+ processing: result.processing,
13506
+ workflowRunId: result.workflowRunId,
13507
+ outreach: result.outreach,
13508
+ message: result.processing
13509
+ ? `Email recovery is running in the background. Check: salesprompter affiliate:status ${runId}`
13510
+ : "Email recovery finished and new verified leads were added."
13370
13511
  });
13371
13512
  });
13372
13513
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "description": "Sales workflow CLI for guided lead generation, enrichment, scoring, and sync.",
5
5
  "author": "Daniel Sinewe <hello@danielsinewe.com>",
6
6
  "type": "module",