salesprompter-cli 0.1.73 → 0.1.74
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 +31 -0
- package/dist/cli.js +49 -0
- package/dist/company-leads.js +175 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,37 @@ 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
|
+
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.
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"companies": [
|
|
50
|
+
{ "name": "Your target company", "companyId": "123", "maxContacts": 20 },
|
|
51
|
+
{ "name": "Company awaiting identity review", "maxContacts": 10 }
|
|
52
|
+
],
|
|
53
|
+
"candidatesPerDepartment": 50
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
salesprompter leads:at-companies --brief companies.json --dry-run
|
|
59
|
+
salesprompter leads:at-companies --brief companies.json --out-dir ./company-research
|
|
60
|
+
# Repeat to resume the next batch; completed searches are reused.
|
|
61
|
+
salesprompter leads:at-companies --brief companies.json --out-dir ./company-research
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
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.
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
|
|
41
72
|
- guided setup and auth
|
|
42
73
|
- product and market discovery
|
|
43
74
|
- Sales Navigator search orchestration
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ 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";
|
|
18
19
|
import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, readAuthSession, requireAuthSession, shouldBypassAuth, verifySession, writeAuthSession } from "./auth.js";
|
|
19
20
|
import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
|
|
20
21
|
import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
|
|
@@ -706,6 +707,7 @@ const cliPacks = [
|
|
|
706
707
|
commands: [
|
|
707
708
|
"leads:discover",
|
|
708
709
|
"leads:collect",
|
|
710
|
+
"leads:at-companies",
|
|
709
711
|
"leads:download",
|
|
710
712
|
"leads:enrich-import",
|
|
711
713
|
"search:run",
|
|
@@ -776,6 +778,7 @@ const helpVisibleCommandNames = new Set([
|
|
|
776
778
|
"auth:whoami",
|
|
777
779
|
"llm:ready",
|
|
778
780
|
"leads:download",
|
|
781
|
+
"leads:at-companies",
|
|
779
782
|
"contacts:find-linkedin-urls",
|
|
780
783
|
"companies:find-linkedin-urls",
|
|
781
784
|
"contacts:process-emails",
|
|
@@ -15131,6 +15134,52 @@ program
|
|
|
15131
15134
|
.action(async (options) => {
|
|
15132
15135
|
printOutput(await runSalesNavigatorPeopleCollectCommand(options));
|
|
15133
15136
|
});
|
|
15137
|
+
program
|
|
15138
|
+
.command("leads:at-companies")
|
|
15139
|
+
.description("Find senior contacts at named companies, balanced across functions; export a review shortlist.")
|
|
15140
|
+
.requiredOption("--brief <path>", "JSON company list with verified company IDs and optional role criteria")
|
|
15141
|
+
.option("--out-dir <path>", "Private output directory and resume checkpoint", "./company-leads")
|
|
15142
|
+
.option("--max-searches <number>", "Maximum company/function searches this invocation", "12")
|
|
15143
|
+
.option("--browser-relay-port <number>", "Use the signed-in Codex browser through a loopback relay")
|
|
15144
|
+
.option("--dry-run", "Preview targeting and unresolved companies without network calls", false)
|
|
15145
|
+
.action(async (options) => {
|
|
15146
|
+
const brief = await readJsonFile(path.resolve(options.brief), CompanyBriefSchema);
|
|
15147
|
+
const jobs = planCompanySearches(brief);
|
|
15148
|
+
const maxSearches = z.coerce.number().int().min(1).max(10000).parse(options.maxSearches);
|
|
15149
|
+
if (options.dryRun) {
|
|
15150
|
+
printOutput({ status: "ok", dryRun: true, searches: jobs, unresolvedCompanies: brief.companies.filter(c => !c.companyId), maxSearches, outreachStarted: false, emailEnrichmentStarted: false });
|
|
15151
|
+
return;
|
|
15152
|
+
}
|
|
15153
|
+
const session = await requireAuthSession();
|
|
15154
|
+
const orgId = session.user.orgId;
|
|
15155
|
+
if (!orgId)
|
|
15156
|
+
throw new Error("Choose a Salesprompter workspace before company research.");
|
|
15157
|
+
const port = options.browserRelayPort == null ? null : z.coerce.number().int().min(1).max(65535).parse(options.browserRelayPort);
|
|
15158
|
+
const relay = port == null ? null : await createLocalAccountSearchBrowserRelay(port);
|
|
15159
|
+
const config = relay ? null : await readLinkedInDirectLookupConfig();
|
|
15160
|
+
let startedSearches = 0;
|
|
15161
|
+
try {
|
|
15162
|
+
const report = await runCompanyResearch({ brief, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches,
|
|
15163
|
+
search: async (job) => {
|
|
15164
|
+
if (startedSearches++ > 0)
|
|
15165
|
+
await delay(randomIntegerBetween(5000, 8000));
|
|
15166
|
+
process.stderr.write(`Researching ${job.company.name} — ${job.department.name}\n`);
|
|
15167
|
+
const request = relay
|
|
15168
|
+
? { url: buildSalesNavigatorLeadApiUrlFromSearchUrl(job.queryUrl, 100), headers: {} }
|
|
15169
|
+
: buildSalesNavigatorApiRequestFromSearchUrl(job.queryUrl, config, 100);
|
|
15170
|
+
return fetchAllLocalSalesNavigatorPeople(request, {
|
|
15171
|
+
requestedProfiles: brief.candidatesPerDepartment, pageSize: 100, pageDelayMinMs: 5000, pageDelayMaxMs: 8000,
|
|
15172
|
+
retry: { maxRetries: 2, retryBaseDelayMs: 2000, retryMaxDelayMs: 10000 },
|
|
15173
|
+
executeRequest: relay ? request => relay.request(request) : undefined,
|
|
15174
|
+
});
|
|
15175
|
+
},
|
|
15176
|
+
});
|
|
15177
|
+
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 });
|
|
15178
|
+
}
|
|
15179
|
+
finally {
|
|
15180
|
+
await relay?.close();
|
|
15181
|
+
}
|
|
15182
|
+
});
|
|
15134
15183
|
program
|
|
15135
15184
|
.command("leads:download")
|
|
15136
15185
|
.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
|
+
}
|
package/package.json
CHANGED