salesprompter-cli 0.1.79 → 0.1.81
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 +21 -2
- package/dist/chrome-browser.js +186 -0
- package/dist/cli.js +58 -7
- package/dist/company-leads.js +2 -1
- package/dist/session-recovery.js +31 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# salesprompter-cli
|
|
2
2
|
|
|
3
|
+
Requires Node.js 22.12 or newer (Node.js 24 LTS recommended). Chrome research also requires an installed Google Chrome.
|
|
4
|
+
|
|
3
5
|
Salesprompter CLI is a terminal workflow for lead generation, enrichment, scoring, and sync.
|
|
4
6
|
|
|
5
7
|
It helps operators and agents go from company, market, or product input to qualified outbound-ready leads with a product-facing command surface and machine-readable output.
|
|
@@ -48,7 +50,19 @@ Use `--report-only` to refresh saved reports without contacting LinkedIn. The wo
|
|
|
48
50
|
|
|
49
51
|
For a manually verified legal-name alias, add `verifiedEmployerAliases: [{ "name": "Exact source employer name", "evidenceUrl": "https://official.example/legal" }]` to that company. The CLI records the evidence link but does not independently verify its contents. Aliases never bypass the numeric current-company ID check and are never inferred from similar names. Changing the brief requires a new output directory; no existing review rows are silently promoted.
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
For standalone browser research, sign in once to the CLI-owned Chrome profile, then select Chrome:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
salesprompter browser:connect
|
|
57
|
+
salesprompter leads:at-companies --brief companies.json --out-dir ./research \
|
|
58
|
+
--resolve-companies --all --browser chrome
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`browser:connect` opens a separate Chrome window and waits up to ten minutes for your manual Sales Navigator sign-in (change with `--wait <seconds>`). It confirms a successful native authenticated request, then closes its own browser. Google Chrome must be installed. The private profile lives at `~/.config/salesprompter/chrome-research` (or under `SALESPROMPTER_CONFIG_DIR`). Your everyday Chrome profile stays untouched. The control connection uses a private OS pipe, not a debugging port. No cookies or browser storage are read/exported. The CLI keeps only allowlisted native request headers in memory.
|
|
62
|
+
|
|
63
|
+
Research runs reuse that profile in background Chrome, execute the GET requests themselves, and close their own browser when finished. No Codex task, relay worker, extension, or manual request polling is needed. A first sign-in, expired login, or security challenge still requires you: rerun `browser:connect`, then repeat the research command with the same output directory. Rate limits stop immediately without switching accounts. `--browser chrome`, `--browser-relay-port`, and `--wait-for-session` are mutually exclusive. Chrome connects lazily, so completed checkpoints and report-only runs never launch it.
|
|
64
|
+
|
|
65
|
+
The default `--browser session` keeps the extension-credential route. Credentials load only for pending requests; on an auth failure it tries changed credentials for the same identity once. Add `--wait-for-session 300` for a bounded refresh wait. The optional Codex `--browser-relay-port` route still needs its external worker. Salesprompter workspace authentication applies in every mode.
|
|
52
66
|
|
|
53
67
|
Create a JSON brief with verified numeric LinkedIn company IDs. Use each subsidiary's own ID; names alone are not an employer match. Missing IDs stay in the coverage report as unresolved.
|
|
54
68
|
|
|
@@ -58,7 +72,12 @@ For a name-only list, the executable end-to-end research mode is:
|
|
|
58
72
|
salesprompter leads:at-companies --brief companies.json --out-dir ./research --resolve-companies --all
|
|
59
73
|
```
|
|
60
74
|
|
|
61
|
-
The CLI first collects prepared IDs, then resolves missing IDs through paginated Account Search and immediately collects their contacts. It accepts only one exact-name identity from a fully read result set of at most 1,000 companies; fuzzy, shared-ID, oversized and ambiguous matches remain unresolved. Resolutions and misses are saved in the workspace-bound checkpoint and `company-resolutions.json`, without editing the input brief. Repeat the command to resume
|
|
75
|
+
The CLI first collects prepared IDs, then resolves missing IDs through paginated Account Search and immediately collects their contacts. It accepts only one exact-name identity from a fully read result set of at most 1,000 companies; fuzzy, shared-ID, oversized and ambiguous matches remain unresolved. Resolutions and misses are saved in the workspace-bound checkpoint and `company-resolutions.json`, without editing the input brief. Repeat the command to resume. Add `--retry-unresolved --resolve-companies` to intentionally retry saved identity misses in the same directory without repeating successful resolutions or completed searches. `--all` removes the invocation's job-count limit, not LinkedIn pacing or per-function candidate limits. A working LinkedIn session is required for new requests; use `--browser chrome` to eliminate the external browser-worker dependency.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
salesprompter leads:at-companies --brief companies.json --out-dir ./research \
|
|
79
|
+
--resolve-companies --retry-unresolved --all --wait-for-session 300
|
|
80
|
+
```
|
|
62
81
|
|
|
63
82
|
```json
|
|
64
83
|
{
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { chmod, lstat, mkdir } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
const ORIGIN = "https://www.linkedin.com";
|
|
6
|
+
const LOGIN_HELP = "Run salesprompter browser:connect, sign in to LinkedIn Sales Navigator in the dedicated Chrome window, then rerun with the same --out-dir. Saved work is preserved.";
|
|
7
|
+
export class ChromeResearchError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(message, status) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.name = "ChromeResearchError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function validateChromeResearchUrl(value) {
|
|
16
|
+
const url = new URL(value);
|
|
17
|
+
if (url.origin !== ORIGIN || url.username || url.password || url.hash ||
|
|
18
|
+
!/^\/sales-api\/(?:salesApiAccountSearch|salesApiLeadSearch|salesApiCompanies)(?:\/\d+)?$/.test(url.pathname)) {
|
|
19
|
+
throw new Error("Chrome research only permits LinkedIn Sales Navigator company and people GET requests.");
|
|
20
|
+
}
|
|
21
|
+
// Preserve native Rest.li encoding; URLSearchParams can corrupt its syntax.
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/** Only these native request headers enter memory; cookies/storage are never read or exported. */
|
|
25
|
+
export function researchHeaders(headers) {
|
|
26
|
+
const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
27
|
+
if (!lower["csrf-token"] || !lower["x-li-identity"])
|
|
28
|
+
return null;
|
|
29
|
+
return { "csrf-token": lower["csrf-token"], "x-li-identity": lower["x-li-identity"], accept: "*/*", "x-restli-protocol-version": "2.0.0" };
|
|
30
|
+
}
|
|
31
|
+
export function chromeProfileDirectory() {
|
|
32
|
+
return path.resolve(process.env.SALESPROMPTER_CONFIG_DIR?.trim() || path.join(os.homedir(), ".config", "salesprompter"), "chrome-research");
|
|
33
|
+
}
|
|
34
|
+
/** Launch only a CLI-owned profile and private OS pipe; no debugging port or default-profile attachment. */
|
|
35
|
+
export async function launchResearchChrome(visible = false) {
|
|
36
|
+
const profile = chromeProfileDirectory();
|
|
37
|
+
try {
|
|
38
|
+
if ((await lstat(profile)).isSymbolicLink())
|
|
39
|
+
throw new Error("The research Chrome profile must not be a symlink.");
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error.code !== "ENOENT")
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
await mkdir(profile, { recursive: true, mode: 0o700 });
|
|
46
|
+
await chmod(profile, 0o700);
|
|
47
|
+
const { default: puppeteer } = await import("puppeteer-core");
|
|
48
|
+
try {
|
|
49
|
+
return await puppeteer.launch({ channel: "chrome", userDataDir: profile, pipe: true, headless: !visible,
|
|
50
|
+
timeout: 30000, protocolTimeout: 45000, defaultViewport: null,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Launch errors can contain paths/flags. Never forward raw browser stderr or credential-bearing URLs.
|
|
55
|
+
throw new ChromeResearchError("Could not start the dedicated research Chrome. Install/update Google Chrome and close any other salesprompter browser:connect or Chrome research run. Your everyday Chrome can stay open.");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function parseChromeResearchResult(result) {
|
|
59
|
+
if (result.status === 429 || result.status === 999)
|
|
60
|
+
throw new ChromeResearchError(`LinkedIn returned HTTP ${result.status}. Research stopped; wait for cooldown before resuming the same checkpoint. No session switching or retry was attempted.`, result.status);
|
|
61
|
+
if (result.redirected || result.status === 401 || result.status === 403)
|
|
62
|
+
throw new ChromeResearchError(LOGIN_HELP, result.status);
|
|
63
|
+
if (result.status !== 200)
|
|
64
|
+
throw new ChromeResearchError(`LinkedIn research failed with HTTP ${result.status}. Saved work is preserved.`, result.status);
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(result.text);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new ChromeResearchError(`LinkedIn did not return research JSON. ${LOGIN_HELP}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Lazy and serial: reopening a completed checkpoint never starts Chrome. */
|
|
73
|
+
export function createChromeResearchBrowser(options = {}, launch = launchResearchChrome) {
|
|
74
|
+
let browser;
|
|
75
|
+
let page;
|
|
76
|
+
let headers = null;
|
|
77
|
+
let identity;
|
|
78
|
+
let fatal;
|
|
79
|
+
let initialization;
|
|
80
|
+
let closed = false;
|
|
81
|
+
let busy = false;
|
|
82
|
+
const waitMs = options.loginWaitMs ?? 30000;
|
|
83
|
+
if (!Number.isFinite(waitMs) || waitMs < 0 || waitMs > 3600000)
|
|
84
|
+
throw new Error("Chrome login wait must be between 0 and 3600 seconds.");
|
|
85
|
+
async function initialize() {
|
|
86
|
+
browser = await launch(options.visible);
|
|
87
|
+
page = await browser.newPage();
|
|
88
|
+
page.on("response", (response) => {
|
|
89
|
+
const url = new URL(response.url());
|
|
90
|
+
if (url.origin !== ORIGIN)
|
|
91
|
+
return;
|
|
92
|
+
if (response.status() === 429 || response.status() === 999) {
|
|
93
|
+
fatal = new ChromeResearchError(`LinkedIn returned HTTP ${response.status()}. Stop and wait for cooldown before resuming.`, response.status());
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!url.pathname.startsWith("/sales-api/"))
|
|
97
|
+
return;
|
|
98
|
+
if (response.status() !== 200)
|
|
99
|
+
return;
|
|
100
|
+
const next = researchHeaders(response.request().headers());
|
|
101
|
+
if (!next)
|
|
102
|
+
return;
|
|
103
|
+
if (identity && identity !== next["x-li-identity"]) {
|
|
104
|
+
fatal = new ChromeResearchError("LinkedIn identity changed during research. Stopped without switching accounts.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
identity = next["x-li-identity"];
|
|
108
|
+
headers = next;
|
|
109
|
+
});
|
|
110
|
+
options.onProgress?.(options.visible
|
|
111
|
+
? "Sign in to Sales Navigator in the dedicated research Chrome window. The CLI will detect the connection automatically; no extension or browser worker is required."
|
|
112
|
+
: "Connecting to the saved research Chrome profile (no external browser worker).");
|
|
113
|
+
try {
|
|
114
|
+
await page.goto(`${ORIGIN}/sales/search/people`, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
if (!browser.connected)
|
|
118
|
+
throw new ChromeResearchError("Research Chrome closed before connecting.");
|
|
119
|
+
}
|
|
120
|
+
const deadline = Date.now() + waitMs;
|
|
121
|
+
let noticeAt = Date.now() + 30000;
|
|
122
|
+
while (!headers) {
|
|
123
|
+
if (closed)
|
|
124
|
+
throw new ChromeResearchError("Research Chrome connection was cancelled.");
|
|
125
|
+
if (fatal)
|
|
126
|
+
throw fatal;
|
|
127
|
+
if (!browser.connected || page.isClosed())
|
|
128
|
+
throw new ChromeResearchError("Research Chrome was closed before sign-in completed.");
|
|
129
|
+
const location = new URL(page.url());
|
|
130
|
+
const needsLogin = location.origin !== ORIGIN || /\/(?:login|checkpoint|authwall|uas)\b/.test(location.pathname);
|
|
131
|
+
if ((!options.visible && needsLogin) || Date.now() >= deadline)
|
|
132
|
+
throw new ChromeResearchError(LOGIN_HELP);
|
|
133
|
+
if (Date.now() >= noticeAt) {
|
|
134
|
+
options.onProgress?.("Waiting for Sales Navigator sign-in. Complete any sign-in or security check yourself in the research Chrome window.");
|
|
135
|
+
noticeAt += 30000;
|
|
136
|
+
}
|
|
137
|
+
await sleep(250);
|
|
138
|
+
}
|
|
139
|
+
if (fatal)
|
|
140
|
+
throw fatal;
|
|
141
|
+
options.onProgress?.("Research Chrome connected. Requests now run directly in the CLI.");
|
|
142
|
+
}
|
|
143
|
+
async function ready() {
|
|
144
|
+
if (closed)
|
|
145
|
+
throw new Error("Research Chrome is closed.");
|
|
146
|
+
initialization ??= initialize();
|
|
147
|
+
await initialization;
|
|
148
|
+
if (fatal)
|
|
149
|
+
throw fatal;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
// Compatibility with the existing request adapter, without opening any listener.
|
|
153
|
+
port: 0,
|
|
154
|
+
async connect() { await ready(); return { browser: "chrome", connected: true, workerRequired: false, profile: chromeProfileDirectory() }; },
|
|
155
|
+
async request(request) {
|
|
156
|
+
validateChromeResearchUrl(request.url);
|
|
157
|
+
if (busy)
|
|
158
|
+
throw new Error("Chrome research is single-worker; concurrent requests are not allowed.");
|
|
159
|
+
busy = true;
|
|
160
|
+
try {
|
|
161
|
+
await ready();
|
|
162
|
+
if (new URL(page.url()).origin !== ORIGIN)
|
|
163
|
+
throw new ChromeResearchError(LOGIN_HELP);
|
|
164
|
+
// Serialize URL and allowlisted native headers as data, never concatenate raw input into executable code.
|
|
165
|
+
// Credentials remain browser-managed. Reject redirects to login/challenge pages instead of parsing HTML.
|
|
166
|
+
const result = await page.evaluate(`(async () => {
|
|
167
|
+
const response = await fetch(${JSON.stringify(request.url)}, {
|
|
168
|
+
method: 'GET', credentials: 'include', redirect: 'error',
|
|
169
|
+
headers: ${JSON.stringify(headers)}, signal: AbortSignal.timeout(30000)
|
|
170
|
+
});
|
|
171
|
+
const text = await response.text();
|
|
172
|
+
if (text.length > 50 * 1024 * 1024) throw new Error('Research response exceeded 50 MB');
|
|
173
|
+
return {status: response.status, text, redirected: response.redirected, retryAfter: response.headers.get('retry-after')};
|
|
174
|
+
})()`).catch(() => { throw new ChromeResearchError(`Chrome research request could not complete. ${LOGIN_HELP}`); });
|
|
175
|
+
const body = parseChromeResearchResult(result);
|
|
176
|
+
if (fatal)
|
|
177
|
+
throw fatal;
|
|
178
|
+
return { body, retryCount: 0, retryDelayMs: 0 };
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
busy = false;
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
async close() { closed = true; await initialization?.catch(() => { }); await browser?.close(); headers = null; identity = undefined; },
|
|
185
|
+
};
|
|
186
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -16,7 +16,8 @@ import { Command } from "commander";
|
|
|
16
16
|
import { z } from "zod";
|
|
17
17
|
import { AffiliateCopyReviewSchema, renderAffiliateCopyReview } from "./affiliate-copy.js";
|
|
18
18
|
import { CompanyBriefSchema, planCompanySearches, runCompanyResearch } from "./company-leads.js";
|
|
19
|
-
import {
|
|
19
|
+
import { createLazySessionRecovery, waitForFreshSession, isFreshSessionForIdentity } from "./session-recovery.js";
|
|
20
|
+
import { createChromeResearchBrowser } from "./chrome-browser.js";
|
|
20
21
|
import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, readAuthSession, requireAuthSession, shouldBypassAuth, verifySession, writeAuthSession } from "./auth.js";
|
|
21
22
|
import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
|
|
22
23
|
import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
|
|
@@ -777,6 +778,7 @@ const helpVisibleCommandNames = new Set([
|
|
|
777
778
|
"auth:workspace",
|
|
778
779
|
"wizard",
|
|
779
780
|
"auth:whoami",
|
|
781
|
+
"browser:connect",
|
|
780
782
|
"llm:ready",
|
|
781
783
|
"leads:download",
|
|
782
784
|
"leads:at-companies",
|
|
@@ -15135,6 +15137,19 @@ program
|
|
|
15135
15137
|
.action(async (options) => {
|
|
15136
15138
|
printOutput(await runSalesNavigatorPeopleCollectCommand(options));
|
|
15137
15139
|
});
|
|
15140
|
+
program
|
|
15141
|
+
.command("browser:connect")
|
|
15142
|
+
.description("Sign in once to the CLI-owned research Chrome; no external browser worker required.")
|
|
15143
|
+
.option("--wait <seconds>", "Time allowed for manual LinkedIn sign-in", "600")
|
|
15144
|
+
.action(async (options) => {
|
|
15145
|
+
const browser = createChromeResearchBrowser({ visible: true, loginWaitMs: z.coerce.number().int().min(1).max(3600).parse(options.wait) * 1000, onProgress: message => process.stderr.write(`${message}\n`) });
|
|
15146
|
+
try {
|
|
15147
|
+
printOutput({ status: "ok", ...await browser.connect() });
|
|
15148
|
+
}
|
|
15149
|
+
finally {
|
|
15150
|
+
await browser.close();
|
|
15151
|
+
}
|
|
15152
|
+
});
|
|
15138
15153
|
program
|
|
15139
15154
|
.command("leads:at-companies")
|
|
15140
15155
|
.description("Find senior contacts at named companies, balanced across functions; export a review shortlist.")
|
|
@@ -15144,9 +15159,20 @@ program
|
|
|
15144
15159
|
.option("--report-only", "Refresh saved research reports without contacting LinkedIn", false)
|
|
15145
15160
|
.option("--all", "Process all pending company/function searches in this invocation", false)
|
|
15146
15161
|
.option("--resolve-companies", "Resolve unique exact company names and collect their contacts in the same run", false)
|
|
15162
|
+
.option("--retry-unresolved", "Retry cached identity misses without discarding completed searches (requires --resolve-companies)", false)
|
|
15163
|
+
.option("--wait-for-session <seconds>", "Wait for a refreshed extension session after an auth failure; never retry rate limits", "0")
|
|
15147
15164
|
.option("--browser-relay-port <number>", "Use the signed-in Codex browser through a loopback relay")
|
|
15165
|
+
.option("--browser <mode>", "Use chrome for CLI-owned browser research, or session for extension credentials", "session")
|
|
15148
15166
|
.option("--dry-run", "Preview targeting and unresolved companies without network calls", false)
|
|
15149
15167
|
.action(async (options) => {
|
|
15168
|
+
const browserMode = z.enum(["chrome", "session"]).parse(options.browser);
|
|
15169
|
+
if (options.retryUnresolved && !options.resolveCompanies)
|
|
15170
|
+
throw new Error("--retry-unresolved requires --resolve-companies.");
|
|
15171
|
+
const waitMs = z.coerce.number().int().min(0).max(3600).parse(options.waitForSession) * 1000;
|
|
15172
|
+
if (browserMode === "chrome" && (options.browserRelayPort || waitMs))
|
|
15173
|
+
throw new Error("Use one connection mode: --browser chrome, --browser-relay-port, or --wait-for-session.");
|
|
15174
|
+
if (waitMs && options.browserRelayPort)
|
|
15175
|
+
throw new Error("--wait-for-session refreshes extension credentials, not a browser relay. Use one connection mode.");
|
|
15150
15176
|
const brief = await readJsonFile(path.resolve(options.brief), CompanyBriefSchema);
|
|
15151
15177
|
const jobs = planCompanySearches(brief);
|
|
15152
15178
|
const maxSearches = options.all ? Number.MAX_SAFE_INTEGER : z.coerce.number().int().min(1).max(10000).parse(options.maxSearches);
|
|
@@ -15164,12 +15190,37 @@ program
|
|
|
15164
15190
|
return;
|
|
15165
15191
|
}
|
|
15166
15192
|
const port = options.browserRelayPort == null ? null : z.coerce.number().int().min(1).max(65535).parse(options.browserRelayPort);
|
|
15167
|
-
const relay = port == null ? null : await createLocalAccountSearchBrowserRelay(port);
|
|
15168
|
-
|
|
15169
|
-
const
|
|
15170
|
-
|
|
15193
|
+
const relay = browserMode === "chrome" ? createChromeResearchBrowser({ onProgress: message => process.stderr.write(`${message}\n`) }) : port == null ? null : await createLocalAccountSearchBrowserRelay(port);
|
|
15194
|
+
let currentConfig = null;
|
|
15195
|
+
const refresh = async () => {
|
|
15196
|
+
if (relay || shouldDisableLinkedInDirectLookupAutodiscovery())
|
|
15197
|
+
return null;
|
|
15198
|
+
return waitForFreshSession({
|
|
15199
|
+
read: readLocalLinkedInExtensionDirectLookupConfig,
|
|
15200
|
+
accept: next => isFreshSessionForIdentity(currentConfig, next),
|
|
15201
|
+
waitMs,
|
|
15202
|
+
onWaiting: () => writeProgress("Waiting for a refreshed Sales Navigator session from the same LinkedIn identity. Reconnect the Salesprompter extension; the pending search is preserved."),
|
|
15203
|
+
});
|
|
15204
|
+
};
|
|
15205
|
+
const recoverSession = createLazySessionRecovery(async () => {
|
|
15206
|
+
if (relay)
|
|
15207
|
+
return null;
|
|
15208
|
+
try {
|
|
15209
|
+
currentConfig = await readLinkedInDirectLookupConfig();
|
|
15210
|
+
}
|
|
15211
|
+
catch (error) {
|
|
15212
|
+
if (!waitMs && error instanceof Error && error.message.startsWith("Missing LinkedIn direct lookup session"))
|
|
15213
|
+
throw new Error("No LinkedIn session is available. Run salesprompter browser:connect, then rerun this command with --browser chrome and the same --out-dir. No external browser worker is needed.");
|
|
15214
|
+
if (!waitMs || !(error instanceof Error) || !error.message.startsWith("Missing LinkedIn direct lookup session"))
|
|
15215
|
+
throw error;
|
|
15216
|
+
currentConfig = await refresh();
|
|
15217
|
+
if (!currentConfig)
|
|
15218
|
+
throw new Error("No LinkedIn session arrived before the wait expired. Reconnect the extension and rerun with the same --out-dir; saved work is preserved.");
|
|
15219
|
+
}
|
|
15220
|
+
return currentConfig;
|
|
15221
|
+
}, {
|
|
15171
15222
|
isAuthError: error => error instanceof CliImportCompanySessionError || error instanceof LocalSalesNavigatorHttpError && (error.status === 401 || error.status === 403),
|
|
15172
|
-
refresh
|
|
15223
|
+
refresh,
|
|
15173
15224
|
sameSession: (a, b) => a?.cookie === b?.cookie && a?.csrfToken === b?.csrfToken,
|
|
15174
15225
|
beforeRetry: async () => {
|
|
15175
15226
|
writeProgress("Saved LinkedIn session was rejected; trying the latest extension-synced session once.");
|
|
@@ -15178,7 +15229,7 @@ program
|
|
|
15178
15229
|
});
|
|
15179
15230
|
let startedSearches = 0;
|
|
15180
15231
|
try {
|
|
15181
|
-
const report = await runCompanyResearch({ brief, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches,
|
|
15232
|
+
const report = await runCompanyResearch({ brief, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches, retryUnresolved: options.retryUnresolved,
|
|
15182
15233
|
resolveCompany: !options.resolveCompanies ? undefined : async (company) => {
|
|
15183
15234
|
if (startedSearches++ > 0)
|
|
15184
15235
|
await delay(randomIntegerBetween(5000, 8000));
|
package/dist/company-leads.js
CHANGED
|
@@ -243,7 +243,8 @@ async function runCompanyResearchLocked(input) {
|
|
|
243
243
|
for (const [index, company] of brief.companies.entries()) {
|
|
244
244
|
if (performed >= input.maxSearches || resolutionsPerformed >= input.maxSearches)
|
|
245
245
|
break;
|
|
246
|
-
|
|
246
|
+
const savedResolution = state.resolutions?.[String(index)];
|
|
247
|
+
if (company.companyId || (Object.hasOwn(state.resolutions ?? {}, String(index)) && !(input.retryUnresolved && savedResolution === null)))
|
|
247
248
|
continue;
|
|
248
249
|
const resolution = await input.resolveCompany(company);
|
|
249
250
|
resolutionsPerformed++;
|
package/dist/session-recovery.js
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
export function isFreshSessionForIdentity(current, next) {
|
|
2
|
+
return current === null || next.identity === current.identity && (next.cookie !== current.cookie || next.csrfToken !== current.csrfToken);
|
|
3
|
+
}
|
|
4
|
+
/** Poll only the configured credential source. No provider requests or identity rotation. */
|
|
5
|
+
export async function waitForFreshSession(options) {
|
|
6
|
+
const now = options.now ?? Date.now;
|
|
7
|
+
const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
8
|
+
const deadline = now() + options.waitMs;
|
|
9
|
+
let nextNotice = now();
|
|
10
|
+
while (true) {
|
|
11
|
+
const value = await options.read();
|
|
12
|
+
if (value !== null && options.accept(value))
|
|
13
|
+
return value;
|
|
14
|
+
const remaining = deadline - now();
|
|
15
|
+
if (remaining <= 0)
|
|
16
|
+
return null;
|
|
17
|
+
if (now() >= nextNotice) {
|
|
18
|
+
options.onWaiting?.();
|
|
19
|
+
nextNotice = now() + 30000;
|
|
20
|
+
}
|
|
21
|
+
await sleep(Math.min(5000, remaining));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Delay credential access until collection really needs it (cached runs need none). */
|
|
25
|
+
export function createLazySessionRecovery(load, options) {
|
|
26
|
+
let runner;
|
|
27
|
+
return async (collect) => {
|
|
28
|
+
runner ??= createSessionRecovery({ ...options, initial: await load() });
|
|
29
|
+
return runner(collect);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
1
32
|
/** One credential refresh per run; never rotate on rate limits or other failures. */
|
|
2
33
|
export function createSessionRecovery(options) {
|
|
3
34
|
let current = options.initial;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "salesprompter-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.81",
|
|
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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"vercel-build": "npm run build:docs:site"
|
|
32
32
|
},
|
|
33
33
|
"engines": {
|
|
34
|
-
"node": ">=
|
|
34
|
+
"node": ">=22.12.0"
|
|
35
35
|
},
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"cheerio": "^1.2.0",
|
|
71
71
|
"commander": "^14.0.1",
|
|
72
72
|
"pg": "^8.21.0",
|
|
73
|
+
"puppeteer-core": "^25.10.0",
|
|
73
74
|
"zod": "^4.1.5"
|
|
74
75
|
},
|
|
75
76
|
"overrides": {
|