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 +1 -0
- package/dist/auth.js +50 -5
- package/dist/cli.js +91 -10
- package/package.json +1 -1
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
|
-
|
|
33
|
+
'<html lang="en">',
|
|
34
34
|
"<head>",
|
|
35
35
|
'<meta charset="utf-8">',
|
|
36
|
-
"<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
|
-
|
|
60
|
+
'<main class="card" aria-live="polite">',
|
|
61
|
+
'<div class="mark" aria-hidden="true">✓</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 {
|
|
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
|
-
|
|
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
|
|
8755
|
-
|
|
8756
|
-
|
|
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
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
let
|
|
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