salesprompter-cli 0.1.61 → 0.1.63

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
@@ -69,6 +69,7 @@ salesprompter contacts:resolve-emails --in ./contacts.tsv --out-dir ./email-run
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
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.
72
73
  salesprompter leads:collect \
73
74
  --linkedin-url "$SALES_NAV_PEOPLE_URL"
74
75
 
package/dist/auth.js CHANGED
@@ -30,23 +30,55 @@ const AuthSessionSchema = z.object({
30
30
  function buildBrowserCallbackSuccessHtml() {
31
31
  return [
32
32
  "<!doctype html>",
33
- "<html>",
33
+ '<html lang="en">',
34
34
  "<head>",
35
35
  '<meta charset="utf-8">',
36
- "<title>Salesprompter CLI login complete</title>",
36
+ "<title>Connected to Salesprompter</title>",
37
37
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
38
+ '<meta name="color-scheme" content="light dark">',
39
+ "<style>",
40
+ ":root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;color-scheme:light dark}",
41
+ "*{box-sizing:border-box}",
42
+ "body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f7f8fa;color:#172033;padding:24px}",
43
+ ".card{width:min(420px,100%);background:#fff;border:1px solid #e4e8ef;border-radius:20px;padding:36px;box-shadow:0 20px 60px rgba(23,32,51,.10);text-align:center}",
44
+ ".mark{width:52px;height:52px;margin:0 auto 20px;display:grid;place-items:center;border-radius:50%;background:#e9f7ee;color:#18753c;font-size:26px;font-weight:800}",
45
+ "h1{margin:0 0 10px;font-size:24px;line-height:1.2;letter-spacing:-.02em}",
46
+ "p{margin:0;color:#667085;font-size:15px;line-height:1.55}",
47
+ ".hint{margin-top:18px;font-size:13px;color:#98a2b3}",
48
+ "@media(prefers-color-scheme:dark){body{background:#111318;color:#f7f8fa}.card{background:#1b1f27;border-color:#303643;box-shadow:none}p{color:#b7c0ce}.hint{color:#8791a2}.mark{background:#153b27;color:#78d89a}}",
49
+ "</style>",
38
50
  "<script>",
39
51
  "if (window.history && typeof window.history.replaceState === 'function') {",
40
52
  " window.history.replaceState(null, document.title, window.location.pathname);",
41
53
  "}",
54
+ "window.addEventListener('load', function () {",
55
+ " window.setTimeout(function () { window.close(); }, 700);",
56
+ "});",
42
57
  "</script>",
43
58
  "</head>",
44
59
  "<body>",
45
- "<p>Salesprompter CLI login complete. You can return to the terminal.</p>",
60
+ '<main class="card" aria-live="polite">',
61
+ '<div class="mark" aria-hidden="true">&#10003;</div>',
62
+ "<h1>You're connected</h1>",
63
+ "<p>Salesprompter is ready in your terminal.</p>",
64
+ '<p class="hint">This tab will close automatically.</p>',
65
+ "</main>",
46
66
  "</body>",
47
67
  "</html>"
48
68
  ].join("");
49
69
  }
70
+ function isSpeculativeBrowserRequest(request) {
71
+ const purposeHeaders = [
72
+ request.headers.purpose,
73
+ request.headers["sec-purpose"],
74
+ request.headers["x-purpose"]
75
+ ]
76
+ .flatMap((value) => (Array.isArray(value) ? value : [value]))
77
+ .filter((value) => typeof value === "string")
78
+ .join(" ")
79
+ .toLowerCase();
80
+ return purposeHeaders.includes("prefetch") || request.headers["next-router-prefetch"] === "1";
81
+ }
50
82
  const DeviceStartResponseSchema = z.object({
51
83
  deviceCode: z.string().min(1),
52
84
  userCode: z.string().min(1),
@@ -264,17 +296,30 @@ export async function loginWithBrowserConnect(options) {
264
296
  response.end("Not found");
265
297
  return;
266
298
  }
299
+ response.setHeader("Cache-Control", "no-store, max-age=0");
300
+ response.setHeader("Pragma", "no-cache");
301
+ response.setHeader("Referrer-Policy", "no-referrer");
302
+ response.setHeader("X-Content-Type-Options", "nosniff");
303
+ if (request.method !== "GET" || isSpeculativeBrowserRequest(request)) {
304
+ response.statusCode = 204;
305
+ response.end();
306
+ return;
307
+ }
267
308
  const accessToken = requestUrl.searchParams.get("access_token") ?? "";
268
309
  const responseState = requestUrl.searchParams.get("state") ?? "";
269
- if (accessToken.trim().length === 0 || responseState.trim().length === 0) {
310
+ if (accessToken.trim().length === 0 || responseState.trim().length === 0 || responseState !== state) {
270
311
  response.statusCode = 400;
271
312
  response.end("Invalid login response");
272
313
  return;
273
314
  }
274
315
  response.statusCode = 200;
275
316
  response.setHeader("Content-Type", "text/html; charset=utf-8");
317
+ response.setHeader("Connection", "close");
318
+ response.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
319
+ response.once("finish", () => {
320
+ resolveToken?.({ accessToken, state: responseState });
321
+ });
276
322
  response.end(buildBrowserCallbackSuccessHtml());
277
- resolveToken?.({ accessToken, state: responseState });
278
323
  }
279
324
  catch (error) {
280
325
  response.statusCode = 500;
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
@@ -8896,6 +8954,7 @@ async function fetchCompleteLocalSalesNavigatorPeople(sourceQueryUrl, requestHea
8896
8954
  status: "split",
8897
8955
  splitDimension: nextRecoveryDimension.key,
8898
8956
  });
8957
+ await persistCheckpoint();
8899
8958
  }
8900
8959
  const people = [...peopleByKey.values()];
8901
8960
  const reportedCountDrift = rootTotalResults == null ? 0 : Math.max(0, rootTotalResults - people.length);
@@ -8908,6 +8967,7 @@ async function fetchCompleteLocalSalesNavigatorPeople(sourceQueryUrl, requestHea
8908
8967
  !partitionCoverageComplete) {
8909
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.`);
8910
8969
  }
8970
+ await persistCheckpoint();
8911
8971
  return {
8912
8972
  people,
8913
8973
  totalResults: rootTotalResults,
@@ -8915,6 +8975,7 @@ async function fetchCompleteLocalSalesNavigatorPeople(sourceQueryUrl, requestHea
8915
8975
  coverageProof: partitionCoverageComplete
8916
8976
  ? "partitioned-canonical-union"
8917
8977
  : "direct-pagination",
8978
+ resumedFromCheckpoint: checkpoint != null,
8918
8979
  reportedCountDrift,
8919
8980
  fetchedPages,
8920
8981
  sourceQueryUrl,
@@ -13600,6 +13661,13 @@ program
13600
13661
  }
13601
13662
  });
13602
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
+ }
13603
13671
  async function runSalesNavigatorPeopleCollectCommand(options) {
13604
13672
  const linkedInUrl = z.string().url().parse(options.linkedinUrl);
13605
13673
  const pageSize = z.coerce.number().int().min(1).max(100).parse(options.pageSize);
@@ -13621,6 +13689,11 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13621
13689
  .max(accessibleResultLimit)
13622
13690
  .parse(options.maxResults);
13623
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;
13624
13697
  if (options.dryRun) {
13625
13698
  const payload = {
13626
13699
  status: "ok",
@@ -13629,6 +13702,7 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13629
13702
  maxResults,
13630
13703
  accessibleResultLimit,
13631
13704
  complete,
13705
+ resumable: complete,
13632
13706
  destination: "/leads/cli-imports"
13633
13707
  };
13634
13708
  if (options.out)
@@ -13676,6 +13750,7 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13676
13750
  collected = complete
13677
13751
  ? await fetchCompleteLocalSalesNavigatorPeople(normalizedSourceUrl, parsedRequest.headers, {
13678
13752
  maxResultsPerSearch: maxResults,
13753
+ checkpointPath,
13679
13754
  ...collectorOptions,
13680
13755
  })
13681
13756
  : await fetchAllLocalSalesNavigatorPeople(parsedRequest, {
@@ -13704,6 +13779,7 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13704
13779
  ? {
13705
13780
  collectionMode: adaptiveCollection.collectionMode,
13706
13781
  coverageProof: adaptiveCollection.coverageProof,
13782
+ resumedFromCheckpoint: adaptiveCollection.resumedFromCheckpoint,
13707
13783
  accessibleResultLimit,
13708
13784
  reportedCountDrift: adaptiveCollection.reportedCountDrift,
13709
13785
  sliceCount: adaptiveCollection.slices.filter((slice) => slice.status === "collected").length,
@@ -13736,6 +13812,7 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13736
13812
  accessibleResultLimit,
13737
13813
  collectionMode: adaptiveCollection ? "adaptive" : "bounded",
13738
13814
  coverageProof: adaptiveCollection?.coverageProof ?? "bounded-pagination",
13815
+ resumedFromCheckpoint: adaptiveCollection?.resumedFromCheckpoint ?? false,
13739
13816
  sliceCount: adaptiveCollection?.slices.filter((slice) => slice.status === "collected")
13740
13817
  .length ?? 1,
13741
13818
  runId: imported.runId,
@@ -13744,6 +13821,9 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13744
13821
  workspaceUrl: `${session.apiBaseUrl}/leads/cli-imports?runId=` +
13745
13822
  encodeURIComponent(imported.runId)
13746
13823
  };
13824
+ if (checkpointPath) {
13825
+ await rm(checkpointPath, { force: true });
13826
+ }
13747
13827
  if (options.out)
13748
13828
  await writeJsonFile(options.out, payload);
13749
13829
  return payload;
@@ -14602,6 +14682,7 @@ program
14602
14682
  .requiredOption("--linkedin-url <url>", "Sales Navigator people search URL")
14603
14683
  .option("--max-results <number>", "Bounded collection size; defaults to exhaustive collection with a 2500-person window")
14604
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")
14605
14686
  .option("--curl-file <path>", "Optional copied Sales Navigator Lead Search curl request")
14606
14687
  .option("--browser-relay-port <number>", "Use the signed-in browser through a loopback relay")
14607
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.61",
3
+ "version": "0.1.63",
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",