salesprompter-cli 0.1.73 → 0.1.75

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
@@ -38,6 +38,39 @@ For headless or automation use, generate a CLI token in the app and run `salespr
38
38
 
39
39
  ## What it does
40
40
 
41
+ ### Contacts at a named list of companies
42
+
43
+ `leads:at-companies` researches Director, Head-of, VP and C-level contacts across functions. It exports a shortlist for review without starting email enrichment or outreach.
44
+
45
+ If LinkedIn rejects the saved session, the command tries a different local extension-synced session once and keeps completed searches. If none works, reconnect the extension or use `--browser-relay-port` with a signed-in browser worker. Rate limits stop the run without switching sessions.
46
+
47
+ 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.
48
+
49
+ ```json
50
+ {
51
+ "companies": [
52
+ { "name": "Your target company", "companyId": "123", "maxContacts": 20 },
53
+ { "name": "Company awaiting identity review", "maxContacts": 10 }
54
+ ],
55
+ "candidatesPerDepartment": 50
56
+ }
57
+ ```
58
+
59
+ ```bash
60
+ salesprompter leads:at-companies --brief companies.json --dry-run
61
+ salesprompter leads:at-companies --brief companies.json --out-dir ./company-research
62
+ # Repeat to resume the next batch; completed searches are reused.
63
+ salesprompter leads:at-companies --brief companies.json --out-dir ./company-research
64
+ ```
65
+
66
+ The default six functions are Digital Marketing & CRM, Digital Product & UX, Software Development, IT, Data & AI, and HR. Override them with `departments: [{ "name": "Security", "terms": ["security", "ciso"] }]`. Optional `regionIds` applies explicit Sales Navigator geography filters; no country is inferred from a company name or headquarters.
67
+
68
+ Contacts must match the current company ID, a senior title and the requested function. Selection alternates between functions, deduplicates profile URLs and never pads a company to its ceiling. The default ceiling is 20; each company can override it. Head-of roles classified as experienced managers are searched, but generic manager titles do not pass the final seniority check.
69
+
70
+ Outputs are private local `contacts.csv`, `coverage.json` (including rejected/unresolved matches and per-function counts), and `checkpoint.json`. A completed search batch is not exhaustive coverage: each function collects at most `candidatesPerDepartment` candidates and the report records LinkedIn's reported total and any shortfall. Default `--max-searches 12` bounds each invocation. Increase it explicitly for larger batches. HTTP 429/999 stops the run and preserves completed searches; resume after cooldown. An interrupted individual search may be repeated.
71
+
72
+ Live research requires Salesprompter workspace login and a LinkedIn session. `--browser-relay-port` uses the existing signed-in browser relay. Checkpoints are bound to both the brief and workspace; changed criteria require a new output directory. A concurrent run is refused; after a crash, remove only `.research-lock` after confirming the process stopped. This command does not import the shortlist into the workspace, find emails, or create campaigns. Review company identity, role fit and coverage before downstream use.
73
+
41
74
  - guided setup and auth
42
75
  - product and market discovery
43
76
  - Sales Navigator search orchestration
package/dist/cli.js CHANGED
@@ -15,6 +15,8 @@ import pg from "pg";
15
15
  import { Command } from "commander";
16
16
  import { z } from "zod";
17
17
  import { AffiliateCopyReviewSchema, renderAffiliateCopyReview } from "./affiliate-copy.js";
18
+ import { CompanyBriefSchema, planCompanySearches, runCompanyResearch } from "./company-leads.js";
19
+ import { createSessionRecovery } from "./session-recovery.js";
18
20
  import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, readAuthSession, requireAuthSession, shouldBypassAuth, verifySession, writeAuthSession } from "./auth.js";
19
21
  import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
20
22
  import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
@@ -706,6 +708,7 @@ const cliPacks = [
706
708
  commands: [
707
709
  "leads:discover",
708
710
  "leads:collect",
711
+ "leads:at-companies",
709
712
  "leads:download",
710
713
  "leads:enrich-import",
711
714
  "search:run",
@@ -776,6 +779,7 @@ const helpVisibleCommandNames = new Set([
776
779
  "auth:whoami",
777
780
  "llm:ready",
778
781
  "leads:download",
782
+ "leads:at-companies",
779
783
  "contacts:find-linkedin-urls",
780
784
  "companies:find-linkedin-urls",
781
785
  "contacts:process-emails",
@@ -15131,6 +15135,64 @@ program
15131
15135
  .action(async (options) => {
15132
15136
  printOutput(await runSalesNavigatorPeopleCollectCommand(options));
15133
15137
  });
15138
+ program
15139
+ .command("leads:at-companies")
15140
+ .description("Find senior contacts at named companies, balanced across functions; export a review shortlist.")
15141
+ .requiredOption("--brief <path>", "JSON company list with verified company IDs and optional role criteria")
15142
+ .option("--out-dir <path>", "Private output directory and resume checkpoint", "./company-leads")
15143
+ .option("--max-searches <number>", "Maximum company/function searches this invocation", "12")
15144
+ .option("--browser-relay-port <number>", "Use the signed-in Codex browser through a loopback relay")
15145
+ .option("--dry-run", "Preview targeting and unresolved companies without network calls", false)
15146
+ .action(async (options) => {
15147
+ const brief = await readJsonFile(path.resolve(options.brief), CompanyBriefSchema);
15148
+ const jobs = planCompanySearches(brief);
15149
+ const maxSearches = z.coerce.number().int().min(1).max(10000).parse(options.maxSearches);
15150
+ if (options.dryRun) {
15151
+ printOutput({ status: "ok", dryRun: true, searches: jobs, unresolvedCompanies: brief.companies.filter(c => !c.companyId), maxSearches, outreachStarted: false, emailEnrichmentStarted: false });
15152
+ return;
15153
+ }
15154
+ const session = await requireAuthSession();
15155
+ const orgId = session.user.orgId;
15156
+ if (!orgId)
15157
+ throw new Error("Choose a Salesprompter workspace before company research.");
15158
+ const port = options.browserRelayPort == null ? null : z.coerce.number().int().min(1).max(65535).parse(options.browserRelayPort);
15159
+ const relay = port == null ? null : await createLocalAccountSearchBrowserRelay(port);
15160
+ const config = relay ? null : await readLinkedInDirectLookupConfig();
15161
+ const recoverSession = createSessionRecovery({
15162
+ initial: config,
15163
+ isAuthError: error => error instanceof LocalSalesNavigatorHttpError && (error.status === 401 || error.status === 403),
15164
+ refresh: async () => relay || shouldDisableLinkedInDirectLookupAutodiscovery() ? null : readLocalLinkedInExtensionDirectLookupConfig(),
15165
+ sameSession: (a, b) => a?.cookie === b?.cookie && a?.csrfToken === b?.csrfToken,
15166
+ beforeRetry: async () => {
15167
+ writeProgress("Saved LinkedIn session was rejected; trying the latest extension-synced session once.");
15168
+ await delay(randomIntegerBetween(5000, 8000));
15169
+ },
15170
+ });
15171
+ let startedSearches = 0;
15172
+ try {
15173
+ const report = await runCompanyResearch({ brief, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches,
15174
+ search: async (job) => {
15175
+ if (startedSearches++ > 0)
15176
+ await delay(randomIntegerBetween(5000, 8000));
15177
+ process.stderr.write(`Researching ${job.company.name} — ${job.department.name}\n`);
15178
+ return recoverSession(async (activeConfig) => {
15179
+ const request = relay
15180
+ ? { url: buildSalesNavigatorLeadApiUrlFromSearchUrl(job.queryUrl, 100), headers: {} }
15181
+ : buildSalesNavigatorApiRequestFromSearchUrl(job.queryUrl, activeConfig, 100);
15182
+ return fetchAllLocalSalesNavigatorPeople(request, {
15183
+ requestedProfiles: brief.candidatesPerDepartment, pageSize: 100, pageDelayMinMs: 5000, pageDelayMaxMs: 8000,
15184
+ retry: { maxRetries: 2, retryBaseDelayMs: 2000, retryMaxDelayMs: 10000 },
15185
+ executeRequest: relay ? request => relay.request(request) : undefined,
15186
+ });
15187
+ });
15188
+ },
15189
+ });
15190
+ printOutput({ status: report.status, selected: report.selected.length, completedSearches: report.completedSearches, totalSearches: report.totalSearches, coverage: report.coverage, output: report.output, resumable: true, outreachStarted: false, emailEnrichmentStarted: false });
15191
+ }
15192
+ finally {
15193
+ await relay?.close();
15194
+ }
15195
+ });
15134
15196
  program
15135
15197
  .command("leads:download")
15136
15198
  .alias("leads:export-csv")
@@ -0,0 +1,175 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile, chmod, rmdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { buildSalesNavigatorPeopleSearchUrl } from "./sales-navigator.js";
6
+ export const defaultDepartments = [
7
+ { name: "Digital Marketing & CRM", terms: ["marketing", "crm", "growth"] },
8
+ { name: "Digital Product & UX", terms: ["product", "ux", "design"] },
9
+ { name: "Software Development", terms: ["software", "engineering", "development", "cto"] },
10
+ { name: "IT", terms: ["it", "information technology", "cio"] },
11
+ { name: "Data & AI", terms: ["data", "ai", "artificial intelligence", "analytics"] },
12
+ { name: "HR", terms: ["hr", "human resources", "people", "personal", "chro"] },
13
+ ];
14
+ const companySchema = z.object({
15
+ name: z.string().trim().min(1),
16
+ companyId: z.string().regex(/^[1-9]\d*$/).optional(),
17
+ maxContacts: z.number().int().min(1).max(100).default(20),
18
+ }).strict();
19
+ export const CompanyBriefSchema = z.object({
20
+ companies: z.array(companySchema).min(1),
21
+ departments: z.array(z.object({ name: z.string().trim().min(1), terms: z.array(z.string().trim().min(1)).min(1) }).strict()).min(1).default(defaultDepartments),
22
+ regionIds: z.array(z.string().regex(/^[1-9]\d*$/)).default([]),
23
+ candidatesPerDepartment: z.number().int().min(1).max(100).default(50),
24
+ }).strict().superRefine((brief, ctx) => {
25
+ const ids = brief.companies.flatMap(c => c.companyId ? [c.companyId] : []);
26
+ if (new Set(ids).size !== ids.length)
27
+ ctx.addIssue({ code: "custom", message: "Duplicate company IDs: merge aliases into one target company." });
28
+ const names = brief.departments.map(d => d.name.toLowerCase());
29
+ if (new Set(names).size !== names.length)
30
+ ctx.addIssue({ code: "custom", message: "Department names must be unique." });
31
+ });
32
+ export function planCompanySearches(brief) {
33
+ return brief.companies.filter(c => c.companyId).flatMap(company => brief.departments.map((department, index) => ({
34
+ key: `${company.companyId}:${index}`, company, department,
35
+ queryUrl: buildSalesNavigatorPeopleSearchUrl([
36
+ { type: "CURRENT_COMPANY", values: [{ id: company.companyId, text: company.name, selectionType: "INCLUDED" }] },
37
+ { type: "CURRENT_TITLE", values: department.terms.map(term => ({ text: term, selectionType: "INCLUDED" })) },
38
+ { type: "SENIORITY_LEVEL", values: [{ id: "310", text: "CXO" }, { id: "300", text: "Vice President" }, { id: "220", text: "Director" }, { id: "210", text: "Experienced Manager" }].map(v => ({ ...v, selectionType: "INCLUDED" })) },
39
+ ...(brief.regionIds.length ? [{ type: "REGION", values: brief.regionIds.map(id => ({ id, text: id, selectionType: "INCLUDED" })) }] : []),
40
+ ]),
41
+ })));
42
+ }
43
+ function words(text) { return text.toLowerCase().normalize("NFKC").replace(/[^\p{L}\p{N}]+/gu, " ").trim(); }
44
+ function hasTerm(title, term) { return ` ${words(title)} `.includes(` ${words(term)} `); }
45
+ function senior(title) {
46
+ return /\b(director|head|chief|ceo|cto|cio|cdo|cmo|chro|cpo|vp|vice president|vorstand|geschäftsführ\w*|leiter\w*|leitung)\b/i.test(title)
47
+ && !/\b(assistant|assistent\w*|assistenz|deputy|stellvertret\w*|former|ehemalig\w*)\b/i.test(title);
48
+ }
49
+ function seniorRank(title) {
50
+ if (/\b(chief|ceo|cto|cio|cdo|cmo|chro|cpo|vorstand|geschäftsführ\w*)\b/i.test(title))
51
+ return 0;
52
+ if (/\b(vp|vice president|head|leiter\w*|leitung)\b/i.test(title))
53
+ return 1;
54
+ return 2;
55
+ }
56
+ export function canonicalProfile(value) {
57
+ try {
58
+ const u = new URL(value);
59
+ if (!/(^|\.)linkedin\.com$/i.test(u.hostname) || !/^\/(in|sales\/lead)\/[^/]+/.test(u.pathname))
60
+ return null;
61
+ return `https://www.linkedin.com${u.pathname.replace(/\/$/, "")}`;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ export function shortlistCompanyLeads(brief, results) {
68
+ const selected = [];
69
+ const rejected = [];
70
+ const coverage = [];
71
+ const seen = new Set();
72
+ for (const company of brief.companies) {
73
+ const jobs = planCompanySearches({ ...brief, companies: [company] });
74
+ const buckets = jobs.map(job => {
75
+ const rows = [];
76
+ for (const p of results[job.key]?.people ?? []) {
77
+ const profile = canonicalProfile(p.profileUrl);
78
+ // Prefer the actual current job over a free-form profile headline.
79
+ const raw = p.rawLocalSalesNavigatorResult;
80
+ const position = raw?.currentPositions?.find(x => String(x.companyId ?? x.companyUrn?.split(":").pop() ?? "") === company.companyId);
81
+ const title = String(position?.title ?? p.title ?? "");
82
+ const exactCompany = String(p.companyId ?? "") === company.companyId || Boolean(position);
83
+ const reason = !profile ? "invalid_profile" : !exactCompany ? "company_not_verified" : !senior(title) ? "seniority_not_verified" : !job.department.terms.some(term => hasTerm(title, term)) ? "function_not_verified" : null;
84
+ if (reason) {
85
+ rejected.push({ companyId: company.companyId, profileUrl: p.profileUrl, reason });
86
+ continue;
87
+ }
88
+ rows.push({ profileUrl: profile, fullName: p.fullName ?? "", title, companyId: company.companyId, companyName: company.name, department: job.department.name, location: p.location ?? "", sourceQueryUrl: job.queryUrl, observedAt: p.timestamp ?? "" });
89
+ }
90
+ return rows.sort((a, b) => seniorRank(String(a.title)) - seniorRank(String(b.title)) || String(a.profileUrl).localeCompare(String(b.profileUrl)));
91
+ });
92
+ const local = [];
93
+ // Round-robin selection stops one function consuming the entire company allowance.
94
+ while (local.length < company.maxContacts && buckets.some(b => b.length)) {
95
+ for (const bucket of buckets) {
96
+ let row;
97
+ while ((row = bucket.shift())) {
98
+ const key = String(row.profileUrl);
99
+ if (!seen.has(key)) {
100
+ seen.add(key);
101
+ local.push(row);
102
+ break;
103
+ }
104
+ }
105
+ if (local.length >= company.maxContacts)
106
+ break;
107
+ }
108
+ }
109
+ selected.push(...local);
110
+ coverage.push({ companyName: company.name, companyId: company.companyId ?? null, selected: local.length, ceiling: company.maxContacts,
111
+ status: !company.companyId ? "unresolved_company" : jobs.some(j => !results[j.key]) ? "pending" : local.length ? "review_ready" : "no_verified_matches",
112
+ departments: jobs.map(j => ({ name: j.department.name, selected: local.filter(p => p.department === j.department.name).length, collected: results[j.key]?.people.length ?? null, reported: results[j.key]?.totalResults ?? null, searchComplete: results[j.key]?.totalResults != null ? results[j.key].people.length >= results[j.key].totalResults : null })) });
113
+ }
114
+ return { selected, rejected, coverage, outreachStarted: false, emailEnrichmentStarted: false };
115
+ }
116
+ export function companyLeadCsv(rows) {
117
+ const columns = ["companyName", "companyId", "fullName", "title", "department", "profileUrl", "location", "sourceQueryUrl", "observedAt"];
118
+ const escape = (value) => { let s = String(value ?? ""); if (/^[=+@\-\t\r]/.test(s))
119
+ s = "'" + s; return `"${s.replace(/"/g, '""')}"`; };
120
+ return [columns.join(","), ...rows.map(r => columns.map(c => escape(r[c])).join(","))].join("\n") + "\n";
121
+ }
122
+ export async function runCompanyResearch(input) {
123
+ await mkdir(input.outDir, { recursive: true, mode: 0o700 });
124
+ const lock = path.join(input.outDir, ".research-lock");
125
+ try {
126
+ await mkdir(lock, { mode: 0o700 });
127
+ }
128
+ catch (e) {
129
+ if (e.code === "EEXIST")
130
+ throw new Error("Research directory is locked by another run. If it crashed, remove only .research-lock after confirming the process stopped.");
131
+ throw e;
132
+ }
133
+ try {
134
+ return await runCompanyResearchLocked(input);
135
+ }
136
+ finally {
137
+ await rmdir(lock);
138
+ }
139
+ }
140
+ async function runCompanyResearchLocked(input) {
141
+ const { brief, outDir, scope } = input;
142
+ await mkdir(outDir, { recursive: true, mode: 0o700 });
143
+ const fingerprint = createHash("sha256").update(JSON.stringify({ brief, scope })).digest("hex");
144
+ const checkpoint = path.join(outDir, "checkpoint.json");
145
+ let state = { fingerprint, results: {} };
146
+ try {
147
+ state = JSON.parse(await readFile(checkpoint, "utf8"));
148
+ if (state.fingerprint !== fingerprint)
149
+ throw new Error("Brief or workspace changed. Use a new output directory.");
150
+ }
151
+ catch (e) {
152
+ if (e.code !== "ENOENT")
153
+ throw e;
154
+ }
155
+ const save = async (file, value) => { await writeFile(file + ".tmp", value, { mode: 0o600 }); await chmod(file + ".tmp", 0o600); await rename(file + ".tmp", file); };
156
+ const publish = async () => { const report = shortlistCompanyLeads(brief, state.results); await save(path.join(outDir, "contacts.csv"), companyLeadCsv(report.selected)); await save(path.join(outDir, "coverage.json"), JSON.stringify(report, null, 2)); return report; };
157
+ let performed = 0;
158
+ try {
159
+ for (const job of planCompanySearches(brief)) {
160
+ if (state.results[job.key])
161
+ continue;
162
+ if (performed >= input.maxSearches)
163
+ break;
164
+ state.results[job.key] = await input.search(job);
165
+ performed++;
166
+ await save(checkpoint, JSON.stringify(state));
167
+ }
168
+ }
169
+ catch (e) {
170
+ await publish();
171
+ throw e;
172
+ }
173
+ const report = await publish();
174
+ return { status: "ok", ...report, completedSearches: Object.keys(state.results).length, totalSearches: planCompanySearches(brief).length, output: outDir, resumable: true };
175
+ }
@@ -0,0 +1,31 @@
1
+ /** One credential refresh per run; never rotate on rate limits or other failures. */
2
+ export function createSessionRecovery(options) {
3
+ let current = options.initial;
4
+ let attempted = false;
5
+ const unavailable = () => new Error("Sales Navigator rejected the available LinkedIn session. Completed company searches are preserved. Reconnect the Salesprompter extension, or rerun with --browser-relay-port and a signed-in browser worker. Signing into Salesprompter alone does not refresh LinkedIn.");
6
+ return async function run(collect) {
7
+ try {
8
+ return await collect(current);
9
+ }
10
+ catch (error) {
11
+ if (!options.isAuthError(error))
12
+ throw error;
13
+ if (attempted)
14
+ throw unavailable();
15
+ attempted = true;
16
+ const next = await options.refresh();
17
+ if (!next || options.sameSession(current, next))
18
+ throw unavailable();
19
+ current = next;
20
+ await options.beforeRetry();
21
+ try {
22
+ return await collect(current);
23
+ }
24
+ catch (retryError) {
25
+ if (options.isAuthError(retryError))
26
+ throw unavailable();
27
+ throw retryError;
28
+ }
29
+ }
30
+ };
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
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",