salesprompter-cli 0.1.56 → 0.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,9 +66,9 @@ salesprompter companies:resolve-linkedin-urls --in ./companies.txt --out ./compa
66
66
  salesprompter contacts:resolve-emails --in ./contacts.tsv --out-dir ./email-run --dry-run
67
67
 
68
68
  # Collect a Sales Navigator people search into the active workspace
69
+ # Searches above the native window are split by company headcount and deduplicated.
69
70
  salesprompter leads:collect \
70
- --linkedin-url "$SALES_NAV_PEOPLE_URL" \
71
- --max-results 2000
71
+ --linkedin-url "$SALES_NAV_PEOPLE_URL"
72
72
 
73
73
  # Enrich one or more stored imports: Sales Navigator company -> official domain -> Hunter email
74
74
  # This updates workspace data only. It does not create or start outreach.
package/dist/cli.js CHANGED
@@ -31,7 +31,7 @@ import { crawlLinkedInProductCategory } from "./linkedin-products.js";
31
31
  import { claimLinkedInSessionCookieForCli, claimValidatedSalesNavigatorSessionCookieForCli, createLinkedInSessionSupabaseClient, recordLinkedInSessionCookieAudit, resolveConfiguredEnvValue } from "./linkedin-session.js";
32
32
  import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
33
33
  import { readJsonFile, splitCsv, writeJsonFile, writeTextFile } from "./io.js";
34
- import { buildSalesNavigatorCrawlPreview, buildSalesNavigatorAccountQuery, createSalesNavigatorCrawlSeed, DEFAULT_SALES_NAVIGATOR_ACCOUNT_CRAWL_DIMENSIONS, DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS, buildSalesNavigatorPeopleSearchUrl, buildSalesNavigatorPeopleSlice, deriveSalesNavigatorTitleQuerySeeds, executeSalesNavigatorAdaptiveCrawl, expandSalesNavigatorCrawlAttempt, SalesNavigatorCrawlStopError, SalesNavigatorSliceTooBroadError } from "./sales-navigator.js";
34
+ import { buildSalesNavigatorCrawlPreview, buildSalesNavigatorAccountQuery, buildSalesNavigatorResidualCrawlAttempt, createSalesNavigatorCrawlSeed, DEFAULT_SALES_NAVIGATOR_ACCOUNT_CRAWL_DIMENSIONS, DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS, buildSalesNavigatorPeopleSearchUrl, buildSalesNavigatorPeopleSlice, deriveSalesNavigatorTitleQuerySeeds, executeSalesNavigatorAdaptiveCrawl, expandSalesNavigatorCrawlAttempt, getSalesNavigatorPeopleAccessibleResultLimit, salesNavigatorPeopleSearchHasFilter, SalesNavigatorCrawlStopError, SalesNavigatorSliceTooBroadError } from "./sales-navigator.js";
35
35
  import { buildSalesNavigatorHistoricalBackfillPlan, ensureSalesNavigatorPeopleCount, resolveSalesNavigatorHistoricalBackfillConfig, resolveSalesNavigatorHistoricalBackfillResumeState, resolveSalesNavigatorHistoricalBackfillOrgId, salesNavigatorHistoricalBackfillDefaults } from "./salesnav-backfill.js";
36
36
  const require = createRequire(import.meta.url);
37
37
  const { version: packageVersion } = require("../package.json");
@@ -8676,6 +8676,18 @@ async function fetchAllLocalSalesNavigatorPeople(parsedRequest, options) {
8676
8676
  fetchedPages += 1;
8677
8677
  totalResults =
8678
8678
  totalResults ?? extractLocalSalesNavigatorTotalResults(responseBody);
8679
+ if (totalResults != null &&
8680
+ options.maxAllowedResults != null &&
8681
+ totalResults > Math.max(1, Math.trunc(options.maxAllowedResults))) {
8682
+ throw new SalesNavigatorSliceTooBroadError(`People slice has ${totalResults} results, above ${options.maxAllowedResults}.`, {
8683
+ totalResults,
8684
+ details: {
8685
+ fetchedPages,
8686
+ retryCount,
8687
+ totalDelayMs,
8688
+ },
8689
+ });
8690
+ }
8679
8691
  const pagePeople = normalizeLocalSalesNavigatorPeople(responseBody, pageRequest.url);
8680
8692
  let added = 0;
8681
8693
  for (const person of pagePeople) {
@@ -8712,6 +8724,149 @@ async function fetchAllLocalSalesNavigatorPeople(parsedRequest, options) {
8712
8724
  },
8713
8725
  };
8714
8726
  }
8727
+ function canonicalLocalSalesNavigatorLeadKey(person) {
8728
+ try {
8729
+ const url = new URL(person.profileUrl);
8730
+ url.hash = "";
8731
+ url.search = "";
8732
+ url.hostname = url.hostname.toLowerCase();
8733
+ url.pathname = url.pathname.replace(/\/+$/, "");
8734
+ return url.toString();
8735
+ }
8736
+ catch {
8737
+ return person.profileUrl.trim().toLowerCase();
8738
+ }
8739
+ }
8740
+ function localPeopleSplitDimensions(sourceQueryUrl) {
8741
+ return DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS.filter((dimension) => !salesNavigatorPeopleSearchHasFilter(sourceQueryUrl, dimension.filterType));
8742
+ }
8743
+ async function fetchCompleteLocalSalesNavigatorPeople(sourceQueryUrl, requestHeaders, options) {
8744
+ const root = createSalesNavigatorCrawlSeed({
8745
+ sourceQueryUrl,
8746
+ baselineFilters: [],
8747
+ maxResultsPerSearch: options.maxResultsPerSearch,
8748
+ numberOfProfiles: options.maxResultsPerSearch,
8749
+ slicePreset: "local-salesnav-people-adaptive",
8750
+ searchType: "people",
8751
+ });
8752
+ const dimensions = localPeopleSplitDimensions(root.sourceQueryUrl);
8753
+ const queue = [root];
8754
+ const queuedUrls = new Set([root.slicedQueryUrl]);
8755
+ const peopleByKey = new Map();
8756
+ const slices = [];
8757
+ let rootTotalResults = null;
8758
+ let fetchedPages = 0;
8759
+ let totalDelayMs = 0;
8760
+ let retryCount = 0;
8761
+ while (queue.length > 0) {
8762
+ const attempt = queue.shift();
8763
+ const parsedRequest = {
8764
+ url: buildSalesNavigatorLeadApiUrlFromSearchUrl(attempt.slicedQueryUrl, options.pageSize),
8765
+ headers: requestHeaders,
8766
+ };
8767
+ let collected = null;
8768
+ let tooBroadTotal = null;
8769
+ let probeFetchedPages = 0;
8770
+ try {
8771
+ collected = await fetchAllLocalSalesNavigatorPeople(parsedRequest, {
8772
+ requestedProfiles: options.maxResultsPerSearch,
8773
+ maxAllowedResults: options.maxResultsPerSearch,
8774
+ pageSize: options.pageSize,
8775
+ pageDelayMinMs: options.pageDelayMinMs,
8776
+ pageDelayMaxMs: options.pageDelayMaxMs,
8777
+ retry: options.retry,
8778
+ executeRequest: options.executeRequest,
8779
+ });
8780
+ fetchedPages += collected.fetchedPages;
8781
+ totalDelayMs += collected.pacing.totalDelayMs;
8782
+ retryCount += collected.pacing.retryCount;
8783
+ if (attempt.depth === 0) {
8784
+ rootTotalResults = collected.totalResults;
8785
+ }
8786
+ }
8787
+ catch (error) {
8788
+ if (!(error instanceof SalesNavigatorSliceTooBroadError)) {
8789
+ throw error;
8790
+ }
8791
+ tooBroadTotal = error.totalResults;
8792
+ const details = error.details && typeof error.details === "object"
8793
+ ? error.details
8794
+ : {};
8795
+ probeFetchedPages = Number(details.fetchedPages ?? 1);
8796
+ fetchedPages += probeFetchedPages;
8797
+ retryCount += Number(details.retryCount ?? 0);
8798
+ totalDelayMs += Number(details.totalDelayMs ?? 0);
8799
+ if (attempt.depth === 0) {
8800
+ rootTotalResults = tooBroadTotal;
8801
+ }
8802
+ }
8803
+ const needsSplit = tooBroadTotal != null ||
8804
+ (collected?.totalResults == null &&
8805
+ collected != null &&
8806
+ collected.people.length >= options.maxResultsPerSearch);
8807
+ if (needsSplit) {
8808
+ const usedDimensionKeys = new Set(attempt.splitTrail.map((entry) => entry.key.replace(/-residual$/, "")));
8809
+ const nextDimension = dimensions.find((dimension) => !usedDimensionKeys.has(dimension.key));
8810
+ if (!nextDimension) {
8811
+ throw new Error(`Sales Navigator search still exceeds the ${options.maxResultsPerSearch}-person window after all safe split dimensions were used. Narrow the source search further.`);
8812
+ }
8813
+ const children = expandSalesNavigatorCrawlAttempt(attempt, nextDimension, "people");
8814
+ if (nextDimension.supportsResidualExclusion !== false) {
8815
+ children.push(buildSalesNavigatorResidualCrawlAttempt(attempt, nextDimension, "people"));
8816
+ }
8817
+ for (const child of children) {
8818
+ if (queuedUrls.has(child.slicedQueryUrl))
8819
+ continue;
8820
+ queuedUrls.add(child.slicedQueryUrl);
8821
+ queue.push(child);
8822
+ }
8823
+ slices.push({
8824
+ depth: attempt.depth,
8825
+ splitTrail: attempt.splitTrail,
8826
+ totalResults: tooBroadTotal ?? collected?.totalResults ?? null,
8827
+ collected: 0,
8828
+ fetchedPages: probeFetchedPages || collected?.fetchedPages || 0,
8829
+ status: "split",
8830
+ splitDimension: nextDimension.key,
8831
+ });
8832
+ continue;
8833
+ }
8834
+ if (!collected) {
8835
+ throw new Error("Sales Navigator slice did not return a collection result.");
8836
+ }
8837
+ for (const person of collected.people) {
8838
+ peopleByKey.set(canonicalLocalSalesNavigatorLeadKey(person), person);
8839
+ }
8840
+ slices.push({
8841
+ depth: attempt.depth,
8842
+ splitTrail: attempt.splitTrail,
8843
+ totalResults: collected.totalResults,
8844
+ collected: collected.people.length,
8845
+ fetchedPages: collected.fetchedPages,
8846
+ status: "collected",
8847
+ splitDimension: null,
8848
+ });
8849
+ }
8850
+ const people = [...peopleByKey.values()];
8851
+ if (rootTotalResults != null && people.length < rootTotalResults) {
8852
+ throw new Error(`Adaptive collection found ${people.length} unique people but the root search reported ${rootTotalResults}. No import was written because coverage is incomplete.`);
8853
+ }
8854
+ return {
8855
+ people,
8856
+ totalResults: rootTotalResults,
8857
+ rootTotalResults,
8858
+ fetchedPages,
8859
+ sourceQueryUrl,
8860
+ collectionMode: "adaptive",
8861
+ slices,
8862
+ pacing: {
8863
+ delayMinMs: options.pageDelayMinMs,
8864
+ delayMaxMs: options.pageDelayMaxMs,
8865
+ totalDelayMs,
8866
+ retryCount,
8867
+ },
8868
+ };
8869
+ }
8715
8870
  async function fetchAllLocalSalesNavigatorAccounts(parsedRequest, options) {
8716
8871
  const accounts = [];
8717
8872
  const seen = new Set();
@@ -8913,6 +9068,7 @@ async function importLocalSalesNavigatorPeopleViaApp(session, payload) {
8913
9068
  fetchedPages: payload.fetchedPages,
8914
9069
  people: payload.people,
8915
9070
  rawPayload: {
9071
+ ...payload.rawPayload,
8916
9072
  clientId: workspaceClientId ?? undefined,
8917
9073
  client_id: workspaceClientId ?? undefined,
8918
9074
  leadList: workspaceClientId
@@ -13383,15 +13539,8 @@ program
13383
13539
  }
13384
13540
  });
13385
13541
  });
13386
- const SALES_NAVIGATOR_PEOPLE_MAX_RESULTS = 2_000;
13387
13542
  async function runSalesNavigatorPeopleCollectCommand(options) {
13388
13543
  const linkedInUrl = z.string().url().parse(options.linkedinUrl);
13389
- const maxResults = z.coerce
13390
- .number()
13391
- .int()
13392
- .min(1)
13393
- .max(SALES_NAVIGATOR_PEOPLE_MAX_RESULTS)
13394
- .parse(options.maxResults);
13395
13544
  const pageSize = z.coerce.number().int().min(1).max(100).parse(options.pageSize);
13396
13545
  const normalizedSourceUrl = (() => {
13397
13546
  const source = new URL(linkedInUrl);
@@ -13401,12 +13550,24 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13401
13550
  }
13402
13551
  return source.toString();
13403
13552
  })();
13553
+ const accessibleResultLimit = getSalesNavigatorPeopleAccessibleResultLimit(normalizedSourceUrl);
13554
+ const maxResults = options.maxResults == null
13555
+ ? accessibleResultLimit
13556
+ : z.coerce
13557
+ .number()
13558
+ .int()
13559
+ .min(1)
13560
+ .max(accessibleResultLimit)
13561
+ .parse(options.maxResults);
13562
+ const complete = Boolean(options.complete || options.maxResults == null);
13404
13563
  if (options.dryRun) {
13405
13564
  const payload = {
13406
13565
  status: "ok",
13407
13566
  dryRun: true,
13408
13567
  linkedInUrl: normalizedSourceUrl,
13409
13568
  maxResults,
13569
+ accessibleResultLimit,
13570
+ complete,
13410
13571
  destination: "/leads/cli-imports"
13411
13572
  };
13412
13573
  if (options.out)
@@ -13438,24 +13599,35 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13438
13599
  : await createLocalAccountSearchBrowserRelay(browserRelayPort);
13439
13600
  let collected;
13440
13601
  try {
13441
- collected = await fetchAllLocalSalesNavigatorPeople(parsedRequest, {
13442
- requestedProfiles: maxResults,
13602
+ const collectorOptions = {
13443
13603
  pageSize,
13444
13604
  pageDelayMinMs,
13445
13605
  pageDelayMaxMs,
13446
13606
  retry: {
13447
13607
  maxRetries: 2,
13448
13608
  retryBaseDelayMs: 2_000,
13449
- retryMaxDelayMs: 10_000
13609
+ retryMaxDelayMs: 10_000,
13450
13610
  },
13451
13611
  executeRequest: browserRelay
13452
13612
  ? (request) => browserRelay.request(request)
13453
- : undefined
13454
- });
13613
+ : undefined,
13614
+ };
13615
+ collected = complete
13616
+ ? await fetchCompleteLocalSalesNavigatorPeople(normalizedSourceUrl, parsedRequest.headers, {
13617
+ maxResultsPerSearch: maxResults,
13618
+ ...collectorOptions,
13619
+ })
13620
+ : await fetchAllLocalSalesNavigatorPeople(parsedRequest, {
13621
+ requestedProfiles: maxResults,
13622
+ ...collectorOptions,
13623
+ });
13455
13624
  }
13456
13625
  finally {
13457
13626
  await browserRelay?.close();
13458
13627
  }
13628
+ const adaptiveCollection = "collectionMode" in collected && collected.collectionMode === "adaptive"
13629
+ ? collected
13630
+ : null;
13459
13631
  const imported = await importLocalSalesNavigatorPeopleViaApp(session, {
13460
13632
  sourceQueryUrl: normalizedSourceUrl,
13461
13633
  people: collected.people,
@@ -13463,8 +13635,21 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13463
13635
  fetchedPages: collected.fetchedPages,
13464
13636
  pacing: collected.pacing,
13465
13637
  maxResultsPerSearch: maxResults,
13466
- numberOfProfiles: maxResults,
13467
- slicePreset: "local-salesnav-people"
13638
+ numberOfProfiles: adaptiveCollection ? collected.people.length : maxResults,
13639
+ slicePreset: adaptiveCollection
13640
+ ? "local-salesnav-people-adaptive"
13641
+ : "local-salesnav-people",
13642
+ rawPayload: adaptiveCollection
13643
+ ? {
13644
+ collectionMode: adaptiveCollection.collectionMode,
13645
+ accessibleResultLimit,
13646
+ sliceCount: adaptiveCollection.slices.filter((slice) => slice.status === "collected").length,
13647
+ slices: adaptiveCollection.slices,
13648
+ }
13649
+ : {
13650
+ collectionMode: "bounded",
13651
+ accessibleResultLimit,
13652
+ },
13468
13653
  });
13469
13654
  const payload = {
13470
13655
  status: "ok",
@@ -13480,6 +13665,10 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13480
13665
  ? null
13481
13666
  : Math.max(0, collected.totalResults - collected.people.length),
13482
13667
  fetchedPages: collected.fetchedPages,
13668
+ accessibleResultLimit,
13669
+ collectionMode: adaptiveCollection ? "adaptive" : "bounded",
13670
+ sliceCount: adaptiveCollection?.slices.filter((slice) => slice.status === "collected")
13671
+ .length ?? 1,
13483
13672
  runId: imported.runId,
13484
13673
  imported: imported.imported,
13485
13674
  upserted: imported.upserted,
@@ -14247,12 +14436,15 @@ async function runSalesNavigatorImportEnrichmentCommand(options) {
14247
14436
  async function runAffiliateLaunchCommand(options) {
14248
14437
  const affiliateLink = z.string().url().parse(options.affiliateLink);
14249
14438
  const linkedInUrl = z.string().url().parse(options.linkedinUrl);
14250
- const maxResults = z.coerce
14251
- .number()
14252
- .int()
14253
- .min(1)
14254
- .max(SALES_NAVIGATOR_PEOPLE_MAX_RESULTS)
14255
- .parse(options.maxResults);
14439
+ const accessibleResultLimit = getSalesNavigatorPeopleAccessibleResultLimit(linkedInUrl);
14440
+ const maxResults = options.maxResults == null
14441
+ ? accessibleResultLimit
14442
+ : z.coerce
14443
+ .number()
14444
+ .int()
14445
+ .min(1)
14446
+ .max(accessibleResultLimit)
14447
+ .parse(options.maxResults);
14256
14448
  const session = await requireAuthSession();
14257
14449
  const isSalesNavigatorPeopleSearch = new URL(linkedInUrl).pathname.includes("/sales/search/people");
14258
14450
  let localResults;
@@ -14325,7 +14517,7 @@ function addAffiliateAudienceOptions(command) {
14325
14517
  return command
14326
14518
  .requiredOption("--affiliate-link <url>", "Affiliate link to promote")
14327
14519
  .requiredOption("--linkedin-url <url>", "Sales Navigator people or LinkedIn content search URL")
14328
- .option("--max-results <number>", "Maximum audience results (people searches: 2000)", String(SALES_NAVIGATOR_PEOPLE_MAX_RESULTS))
14520
+ .option("--max-results <number>", "Maximum audience results (people searches: 2500; Connections of: 1000)")
14329
14521
  .option("--curl-file <path>", "Optional copied Sales Navigator Lead Search curl request")
14330
14522
  .option("--browser-relay-port <number>", "Use the signed-in browser through a loopback relay")
14331
14523
  .option("--page-size <number>", "Direct Sales Navigator page size", "100")
@@ -14339,7 +14531,8 @@ program
14339
14531
  .alias("leads:collect")
14340
14532
  .description("Collect a Sales Navigator people search into the active workspace.")
14341
14533
  .requiredOption("--linkedin-url <url>", "Sales Navigator people search URL")
14342
- .option("--max-results <number>", "Maximum people to collect", String(SALES_NAVIGATOR_PEOPLE_MAX_RESULTS))
14534
+ .option("--max-results <number>", "Bounded collection size; defaults to exhaustive collection with a 2500-person window")
14535
+ .option("--complete", "Split oversized searches by company headcount and deduplicate the complete result", false)
14343
14536
  .option("--curl-file <path>", "Optional copied Sales Navigator Lead Search curl request")
14344
14537
  .option("--browser-relay-port <number>", "Use the signed-in browser through a loopback relay")
14345
14538
  .option("--page-size <number>", "Direct Sales Navigator page size", "100")
@@ -92,6 +92,45 @@ export const DEFAULT_SALES_NAVIGATOR_PEOPLE_SLICE_FILTERS = [
92
92
  ];
93
93
  const LINKEDIN_SALES_NAVIGATOR_PEOPLE_SEARCH_URL = "https://www.linkedin.com/sales/search/people";
94
94
  const LINKEDIN_SALES_NAVIGATOR_ACCOUNT_SEARCH_URL = "https://www.linkedin.com/sales/search/company";
95
+ export const SALES_NAVIGATOR_PEOPLE_MAX_RESULTS = 2_500;
96
+ export const SALES_NAVIGATOR_CONNECTIONS_OF_MAX_RESULTS = 1_000;
97
+ export const SALES_NAVIGATOR_ACCOUNT_MAX_RESULTS = 1_000;
98
+ function decodeSalesNavigatorQueryValue(value) {
99
+ let decoded = value;
100
+ for (let attempt = 0; attempt < 3; attempt += 1) {
101
+ try {
102
+ const next = decodeURIComponent(decoded);
103
+ if (next === decoded)
104
+ break;
105
+ decoded = next;
106
+ }
107
+ catch {
108
+ break;
109
+ }
110
+ }
111
+ return decoded;
112
+ }
113
+ function getSalesNavigatorQueryValue(sourceQueryUrl) {
114
+ const url = new URL(sourceQueryUrl);
115
+ const queryFromSearch = url.searchParams.get("query");
116
+ const queryFromHash = url.hash.startsWith("#query=")
117
+ ? new URLSearchParams(url.hash.slice(1)).get("query")
118
+ : null;
119
+ return decodeSalesNavigatorQueryValue(queryFromSearch ?? queryFromHash ?? "");
120
+ }
121
+ export function isSalesNavigatorConnectionsOfSearch(sourceQueryUrl) {
122
+ const queryValue = getSalesNavigatorQueryValue(sourceQueryUrl);
123
+ return /(?:type\s*:\s*)?(?:CONNECTIONS?_OF|CONNECTION_OF|TEAMLINK_CONNECTION_OF)\b/i.test(queryValue) || /\bconnections?\s+of\b/i.test(queryValue);
124
+ }
125
+ export function getSalesNavigatorPeopleAccessibleResultLimit(sourceQueryUrl) {
126
+ return isSalesNavigatorConnectionsOfSearch(sourceQueryUrl)
127
+ ? SALES_NAVIGATOR_CONNECTIONS_OF_MAX_RESULTS
128
+ : SALES_NAVIGATOR_PEOPLE_MAX_RESULTS;
129
+ }
130
+ export function salesNavigatorPeopleSearchHasFilter(sourceQueryUrl, filterType) {
131
+ const escapedFilterType = filterType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
132
+ return new RegExp(`\\btype\\s*:\\s*${escapedFilterType}\\b`, "i").test(getSalesNavigatorQueryValue(sourceQueryUrl));
133
+ }
95
134
  export const DEFAULT_SALES_NAVIGATOR_CRAWL_BASELINE_FILTERS = [
96
135
  {
97
136
  type: "FUNCTION",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.56",
3
+ "version": "0.1.57",
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",