salesprompter-cli 0.1.60 → 0.1.62

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 +2 -0
  2. package/dist/cli.js +103 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -68,6 +68,8 @@ salesprompter contacts:resolve-emails --in ./contacts.tsv --out-dir ./email-run
68
68
  # Collect a Sales Navigator people search into the active workspace
69
69
  # Normal people searches use 2,500-result slices; Connections-of searches use 1,000.
70
70
  # Oversized searches split by company headcount and deduplicate by canonical profile URL.
71
+ # LinkedIn headline drift is retained as metadata after the complete partition is proven.
72
+ # Exhaustive runs resume completed slices automatically after interruptions or rate limits.
71
73
  salesprompter leads:collect \
72
74
  --linkedin-url "$SALES_NAV_PEOPLE_URL"
73
75
 
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { access, appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import { access, appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
5
  import { createServer } from "node:http";
5
6
  import { createRequire } from "node:module";
6
7
  import os from "node:os";
@@ -7662,7 +7663,8 @@ function collectPotentialSalesNavigatorRows(value, rows = []) {
7662
7663
  function normalizeLocalSalesNavigatorPeople(responseBody, queryUrl) {
7663
7664
  const seen = new Set();
7664
7665
  const rows = [];
7665
- for (const record of collectPotentialSalesNavigatorRows(responseBody)) {
7666
+ const resultRecords = extractLocalSalesNavigatorElements(responseBody).flatMap((element) => collectPotentialSalesNavigatorRows(element));
7667
+ for (const record of resultRecords) {
7666
7668
  const currentPosition = firstLocalArrayRecord(record, "currentPositions");
7667
7669
  const company = firstLocalNestedRecord(currentPosition, "companyUrnResolutionResult");
7668
7670
  const profileUrl = deriveLocalSalesNavigatorProfileUrl(record);
@@ -8725,6 +8727,27 @@ async function fetchAllLocalSalesNavigatorPeople(parsedRequest, options) {
8725
8727
  };
8726
8728
  }
8727
8729
  const LOCAL_SALES_NAVIGATOR_REPORTED_COUNT_DRIFT_LIMIT = 5;
8730
+ async function readLocalSalesNavigatorPeopleCheckpoint(checkpointPath) {
8731
+ try {
8732
+ await access(checkpointPath);
8733
+ }
8734
+ catch {
8735
+ return null;
8736
+ }
8737
+ const parsed = JSON.parse(await readFile(checkpointPath, "utf8"));
8738
+ if (parsed.version !== 1 ||
8739
+ typeof parsed.sourceQueryUrl !== "string" ||
8740
+ typeof parsed.maxResultsPerSearch !== "number" ||
8741
+ typeof parsed.pageSize !== "number" ||
8742
+ !Array.isArray(parsed.queue) ||
8743
+ !Array.isArray(parsed.queuedUrls) ||
8744
+ !Array.isArray(parsed.rootRecoveryDimensionKeys) ||
8745
+ !Array.isArray(parsed.people) ||
8746
+ !Array.isArray(parsed.slices)) {
8747
+ throw new Error(`Sales Navigator people checkpoint is invalid: ${checkpointPath}`);
8748
+ }
8749
+ return parsed;
8750
+ }
8728
8751
  function canonicalLocalSalesNavigatorLeadKey(person) {
8729
8752
  try {
8730
8753
  const url = new URL(person.profileUrl);
@@ -8751,15 +8774,48 @@ async function fetchCompleteLocalSalesNavigatorPeople(sourceQueryUrl, requestHea
8751
8774
  searchType: "people",
8752
8775
  });
8753
8776
  const dimensions = localPeopleSplitDimensions(root.sourceQueryUrl);
8754
- const queue = [root];
8755
- const queuedUrls = new Set([root.slicedQueryUrl]);
8756
- const rootRecoveryDimensionKeys = new Set();
8777
+ const checkpoint = options.checkpointPath
8778
+ ? await readLocalSalesNavigatorPeopleCheckpoint(options.checkpointPath)
8779
+ : null;
8780
+ if (checkpoint &&
8781
+ (checkpoint.sourceQueryUrl !== sourceQueryUrl ||
8782
+ checkpoint.maxResultsPerSearch !== options.maxResultsPerSearch ||
8783
+ checkpoint.pageSize !== options.pageSize)) {
8784
+ throw new Error("Sales Navigator people checkpoint does not match this query or collection window.");
8785
+ }
8786
+ const queue = checkpoint?.queue ?? [root];
8787
+ const queuedUrls = new Set(checkpoint?.queuedUrls ?? [root.slicedQueryUrl]);
8788
+ const rootRecoveryDimensionKeys = new Set(checkpoint?.rootRecoveryDimensionKeys ?? []);
8757
8789
  const peopleByKey = new Map();
8758
- const slices = [];
8759
- let rootTotalResults = null;
8760
- let fetchedPages = 0;
8761
- let totalDelayMs = 0;
8762
- let retryCount = 0;
8790
+ for (const person of checkpoint?.people ?? []) {
8791
+ peopleByKey.set(canonicalLocalSalesNavigatorLeadKey(person), person);
8792
+ }
8793
+ const slices = checkpoint?.slices ?? [];
8794
+ let rootTotalResults = checkpoint?.rootTotalResults ?? null;
8795
+ let fetchedPages = checkpoint?.fetchedPages ?? 0;
8796
+ let totalDelayMs = checkpoint?.totalDelayMs ?? 0;
8797
+ let retryCount = checkpoint?.retryCount ?? 0;
8798
+ const persistCheckpoint = async () => {
8799
+ if (!options.checkpointPath)
8800
+ return;
8801
+ await writeJsonFile(options.checkpointPath, {
8802
+ version: 1,
8803
+ sourceQueryUrl,
8804
+ maxResultsPerSearch: options.maxResultsPerSearch,
8805
+ pageSize: options.pageSize,
8806
+ queue,
8807
+ queuedUrls: [...queuedUrls],
8808
+ rootRecoveryDimensionKeys: [...rootRecoveryDimensionKeys],
8809
+ people: [...peopleByKey.values()],
8810
+ slices,
8811
+ rootTotalResults,
8812
+ fetchedPages,
8813
+ totalDelayMs,
8814
+ retryCount,
8815
+ updatedAt: new Date().toISOString(),
8816
+ });
8817
+ };
8818
+ await persistCheckpoint();
8763
8819
  while (true) {
8764
8820
  while (queue.length > 0) {
8765
8821
  const attempt = queue.shift();
@@ -8840,6 +8896,7 @@ async function fetchCompleteLocalSalesNavigatorPeople(sourceQueryUrl, requestHea
8840
8896
  status: "split",
8841
8897
  splitDimension: nextDimension.key,
8842
8898
  });
8899
+ await persistCheckpoint();
8843
8900
  continue;
8844
8901
  }
8845
8902
  if (!collected) {
@@ -8857,6 +8914,7 @@ async function fetchCompleteLocalSalesNavigatorPeople(sourceQueryUrl, requestHea
8857
8914
  status: "collected",
8858
8915
  splitDimension: null,
8859
8916
  });
8917
+ await persistCheckpoint();
8860
8918
  }
8861
8919
  const currentReportedCountDrift = rootTotalResults == null
8862
8920
  ? 0
@@ -8865,9 +8923,11 @@ async function fetchCompleteLocalSalesNavigatorPeople(sourceQueryUrl, requestHea
8865
8923
  rootTotalResults <= options.maxResultsPerSearch &&
8866
8924
  currentReportedCountDrift <=
8867
8925
  LOCAL_SALES_NAVIGATOR_REPORTED_COUNT_DRIFT_LIMIT;
8926
+ const partitionCoverageComplete = rootRecoveryDimensionKeys.size > 0;
8868
8927
  if (rootTotalResults == null ||
8869
8928
  peopleByKey.size >= rootTotalResults ||
8870
- currentReportedCountDriftAccepted) {
8929
+ currentReportedCountDriftAccepted ||
8930
+ partitionCoverageComplete) {
8871
8931
  break;
8872
8932
  }
8873
8933
  const nextRecoveryDimension = dimensions.find((dimension) => !rootRecoveryDimensionKeys.has(dimension.key));
@@ -8894,19 +8954,28 @@ async function fetchCompleteLocalSalesNavigatorPeople(sourceQueryUrl, requestHea
8894
8954
  status: "split",
8895
8955
  splitDimension: nextRecoveryDimension.key,
8896
8956
  });
8957
+ await persistCheckpoint();
8897
8958
  }
8898
8959
  const people = [...peopleByKey.values()];
8899
8960
  const reportedCountDrift = rootTotalResults == null ? 0 : Math.max(0, rootTotalResults - people.length);
8900
8961
  const acceptedReportedCountDrift = rootTotalResults != null &&
8901
8962
  rootTotalResults <= options.maxResultsPerSearch &&
8902
8963
  reportedCountDrift <= LOCAL_SALES_NAVIGATOR_REPORTED_COUNT_DRIFT_LIMIT;
8903
- if (reportedCountDrift > 0 && !acceptedReportedCountDrift) {
8964
+ const partitionCoverageComplete = rootRecoveryDimensionKeys.size > 0;
8965
+ if (reportedCountDrift > 0 &&
8966
+ !acceptedReportedCountDrift &&
8967
+ !partitionCoverageComplete) {
8904
8968
  throw new Error(`Adaptive collection found ${people.length} unique people but the root search reported ${rootTotalResults}. No import was written because coverage is incomplete.`);
8905
8969
  }
8970
+ await persistCheckpoint();
8906
8971
  return {
8907
8972
  people,
8908
8973
  totalResults: rootTotalResults,
8909
8974
  rootTotalResults,
8975
+ coverageProof: partitionCoverageComplete
8976
+ ? "partitioned-canonical-union"
8977
+ : "direct-pagination",
8978
+ resumedFromCheckpoint: checkpoint != null,
8910
8979
  reportedCountDrift,
8911
8980
  fetchedPages,
8912
8981
  sourceQueryUrl,
@@ -13592,6 +13661,13 @@ program
13592
13661
  }
13593
13662
  });
13594
13663
  });
13664
+ function getLocalSalesNavigatorPeopleCheckpointPath(sourceQueryUrl, maxResultsPerSearch, pageSize) {
13665
+ const key = createHash("sha256")
13666
+ .update(`${sourceQueryUrl}\n${maxResultsPerSearch}\n${pageSize}`)
13667
+ .digest("hex")
13668
+ .slice(0, 20);
13669
+ return path.join(getSalesprompterConfigDir(), "checkpoints", `salesnav-people-${key}.json`);
13670
+ }
13595
13671
  async function runSalesNavigatorPeopleCollectCommand(options) {
13596
13672
  const linkedInUrl = z.string().url().parse(options.linkedinUrl);
13597
13673
  const pageSize = z.coerce.number().int().min(1).max(100).parse(options.pageSize);
@@ -13613,6 +13689,11 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13613
13689
  .max(accessibleResultLimit)
13614
13690
  .parse(options.maxResults);
13615
13691
  const complete = Boolean(options.complete || options.maxResults == null);
13692
+ const checkpointPath = complete
13693
+ ? options.checkpoint
13694
+ ? path.resolve(options.checkpoint)
13695
+ : getLocalSalesNavigatorPeopleCheckpointPath(normalizedSourceUrl, maxResults, pageSize)
13696
+ : null;
13616
13697
  if (options.dryRun) {
13617
13698
  const payload = {
13618
13699
  status: "ok",
@@ -13621,6 +13702,7 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13621
13702
  maxResults,
13622
13703
  accessibleResultLimit,
13623
13704
  complete,
13705
+ resumable: complete,
13624
13706
  destination: "/leads/cli-imports"
13625
13707
  };
13626
13708
  if (options.out)
@@ -13668,6 +13750,7 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13668
13750
  collected = complete
13669
13751
  ? await fetchCompleteLocalSalesNavigatorPeople(normalizedSourceUrl, parsedRequest.headers, {
13670
13752
  maxResultsPerSearch: maxResults,
13753
+ checkpointPath,
13671
13754
  ...collectorOptions,
13672
13755
  })
13673
13756
  : await fetchAllLocalSalesNavigatorPeople(parsedRequest, {
@@ -13695,6 +13778,8 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13695
13778
  rawPayload: adaptiveCollection
13696
13779
  ? {
13697
13780
  collectionMode: adaptiveCollection.collectionMode,
13781
+ coverageProof: adaptiveCollection.coverageProof,
13782
+ resumedFromCheckpoint: adaptiveCollection.resumedFromCheckpoint,
13698
13783
  accessibleResultLimit,
13699
13784
  reportedCountDrift: adaptiveCollection.reportedCountDrift,
13700
13785
  sliceCount: adaptiveCollection.slices.filter((slice) => slice.status === "collected").length,
@@ -13726,6 +13811,8 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13726
13811
  fetchedPages: collected.fetchedPages,
13727
13812
  accessibleResultLimit,
13728
13813
  collectionMode: adaptiveCollection ? "adaptive" : "bounded",
13814
+ coverageProof: adaptiveCollection?.coverageProof ?? "bounded-pagination",
13815
+ resumedFromCheckpoint: adaptiveCollection?.resumedFromCheckpoint ?? false,
13729
13816
  sliceCount: adaptiveCollection?.slices.filter((slice) => slice.status === "collected")
13730
13817
  .length ?? 1,
13731
13818
  runId: imported.runId,
@@ -13734,6 +13821,9 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13734
13821
  workspaceUrl: `${session.apiBaseUrl}/leads/cli-imports?runId=` +
13735
13822
  encodeURIComponent(imported.runId)
13736
13823
  };
13824
+ if (checkpointPath) {
13825
+ await rm(checkpointPath, { force: true });
13826
+ }
13737
13827
  if (options.out)
13738
13828
  await writeJsonFile(options.out, payload);
13739
13829
  return payload;
@@ -14592,6 +14682,7 @@ program
14592
14682
  .requiredOption("--linkedin-url <url>", "Sales Navigator people search URL")
14593
14683
  .option("--max-results <number>", "Bounded collection size; defaults to exhaustive collection with a 2500-person window")
14594
14684
  .option("--complete", "Split oversized searches by company headcount and deduplicate the complete result", false)
14685
+ .option("--checkpoint <path>", "Optional resume checkpoint; exhaustive runs use a per-query checkpoint by default")
14595
14686
  .option("--curl-file <path>", "Optional copied Sales Navigator Lead Search curl request")
14596
14687
  .option("--browser-relay-port <number>", "Use the signed-in browser through a loopback relay")
14597
14688
  .option("--page-size <number>", "Direct Sales Navigator page size", "100")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.60",
3
+ "version": "0.1.62",
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",