salesprompter-cli 0.1.74 → 0.1.76
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 +9 -1
- package/dist/cli.js +21 -8
- package/dist/company-leads.js +58 -18
- package/dist/session-recovery.js +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,8 @@ For headless or automation use, generate a CLI token in the app and run `salespr
|
|
|
42
42
|
|
|
43
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
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
|
+
|
|
45
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.
|
|
46
48
|
|
|
47
49
|
```json
|
|
@@ -65,7 +67,13 @@ The default six functions are Digital Marketing & CRM, Digital Product & UX, Sof
|
|
|
65
67
|
|
|
66
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.
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
Default role matching includes talent acquisition, business intelligence, digital workplace and German department-head titles. Broad digital/transformation, communications and operational-technology matches go into `review.csv`, not the selected contacts. Mixed-role headlines cannot borrow seniority from an assistant or former role. Current-position evidence overrides a conflicting summary employer or headline.
|
|
71
|
+
|
|
72
|
+
The export retains the source employer name. If LinkedIn returns the target ID with a different employer name, that candidate goes to review too: parent/subsidiary scope and legal-name aliases need an explicit human check.
|
|
73
|
+
|
|
74
|
+
For custom functions, add `reviewTerms` (a subset of `terms`) for discovery terms that should require manual review: `{"name":"IT","terms":["it","digital"],"reviewTerms":["digital"]}`. A specific non-review term in the same senior-role clause can qualify a contact.
|
|
75
|
+
|
|
76
|
+
Outputs are private local `contacts.csv`, `review.csv`, `coverage.json` (including review candidates, rejected/unresolved matches and per-function counts), and `checkpoint.json`. Review counts are candidate/function pairs, not additional unique leads. 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
77
|
|
|
70
78
|
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
79
|
|
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ 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 { createSessionRecovery } from "./session-recovery.js";
|
|
19
20
|
import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, readAuthSession, requireAuthSession, shouldBypassAuth, verifySession, writeAuthSession } from "./auth.js";
|
|
20
21
|
import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
|
|
21
22
|
import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
|
|
@@ -15157,6 +15158,16 @@ program
|
|
|
15157
15158
|
const port = options.browserRelayPort == null ? null : z.coerce.number().int().min(1).max(65535).parse(options.browserRelayPort);
|
|
15158
15159
|
const relay = port == null ? null : await createLocalAccountSearchBrowserRelay(port);
|
|
15159
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
|
+
});
|
|
15160
15171
|
let startedSearches = 0;
|
|
15161
15172
|
try {
|
|
15162
15173
|
const report = await runCompanyResearch({ brief, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches,
|
|
@@ -15164,17 +15175,19 @@ program
|
|
|
15164
15175
|
if (startedSearches++ > 0)
|
|
15165
15176
|
await delay(randomIntegerBetween(5000, 8000));
|
|
15166
15177
|
process.stderr.write(`Researching ${job.company.name} — ${job.department.name}\n`);
|
|
15167
|
-
|
|
15168
|
-
|
|
15169
|
-
|
|
15170
|
-
|
|
15171
|
-
|
|
15172
|
-
|
|
15173
|
-
|
|
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
|
+
});
|
|
15174
15187
|
});
|
|
15175
15188
|
},
|
|
15176
15189
|
});
|
|
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 });
|
|
15190
|
+
printOutput({ status: report.status, selected: report.selected.length, reviewRequired: report.review.length, completedSearches: report.completedSearches, totalSearches: report.totalSearches, coverage: report.coverage, output: report.output, resumable: true, outreachStarted: false, emailEnrichmentStarted: false });
|
|
15178
15191
|
}
|
|
15179
15192
|
finally {
|
|
15180
15193
|
await relay?.close();
|
package/dist/company-leads.js
CHANGED
|
@@ -4,12 +4,12 @@ import path from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { buildSalesNavigatorPeopleSearchUrl } from "./sales-navigator.js";
|
|
6
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"] },
|
|
7
|
+
{ name: "Digital Marketing & CRM", terms: ["marketing", "crm", "growth", "communication"], reviewTerms: ["communication"] },
|
|
8
|
+
{ name: "Digital Product & UX", terms: ["product", "ux", "design"], reviewTerms: [] },
|
|
9
|
+
{ name: "Software Development", terms: ["software", "engineering", "development", "cto", "operational technology"], reviewTerms: ["operational technology"] },
|
|
10
|
+
{ name: "IT", terms: ["it", "information technology", "cio", "digital workplace", "chief digital officer", "cdo", "digital", "transformation"], reviewTerms: ["digital", "transformation"] },
|
|
11
|
+
{ name: "Data & AI", terms: ["data", "ai", "artificial intelligence", "analytics", "business intelligence"], reviewTerms: [] },
|
|
12
|
+
{ name: "HR", terms: ["hr", "human resources", "people", "personal", "chro", "talent"], reviewTerms: [] },
|
|
13
13
|
];
|
|
14
14
|
const companySchema = z.object({
|
|
15
15
|
name: z.string().trim().min(1),
|
|
@@ -18,7 +18,7 @@ const companySchema = z.object({
|
|
|
18
18
|
}).strict();
|
|
19
19
|
export const CompanyBriefSchema = z.object({
|
|
20
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),
|
|
21
|
+
departments: z.array(z.object({ name: z.string().trim().min(1), terms: z.array(z.string().trim().min(1)).min(1), reviewTerms: z.array(z.string().trim().min(1)).default([]) }).strict()).min(1).default(defaultDepartments),
|
|
22
22
|
regionIds: z.array(z.string().regex(/^[1-9]\d*$/)).default([]),
|
|
23
23
|
candidatesPerDepartment: z.number().int().min(1).max(100).default(50),
|
|
24
24
|
}).strict().superRefine((brief, ctx) => {
|
|
@@ -28,6 +28,10 @@ export const CompanyBriefSchema = z.object({
|
|
|
28
28
|
const names = brief.departments.map(d => d.name.toLowerCase());
|
|
29
29
|
if (new Set(names).size !== names.length)
|
|
30
30
|
ctx.addIssue({ code: "custom", message: "Department names must be unique." });
|
|
31
|
+
for (const department of brief.departments) {
|
|
32
|
+
if (department.reviewTerms.some(term => !department.terms.some(t => words(t) === words(term))))
|
|
33
|
+
ctx.addIssue({ code: "custom", message: "Review terms must also be included in department search terms." });
|
|
34
|
+
}
|
|
31
35
|
});
|
|
32
36
|
export function planCompanySearches(brief) {
|
|
33
37
|
return brief.companies.filter(c => c.companyId).flatMap(company => brief.departments.map((department, index) => ({
|
|
@@ -40,12 +44,33 @@ export function planCompanySearches(brief) {
|
|
|
40
44
|
]),
|
|
41
45
|
})));
|
|
42
46
|
}
|
|
43
|
-
function words(text) {
|
|
47
|
+
function words(text) {
|
|
48
|
+
return text.toLowerCase().normalize("NFKC")
|
|
49
|
+
.replace(/\b(marketing|personal|entwicklungs|it)leiter(in)?\b/g, (_, functionName, suffix) => `${functionName === "personal" ? "hr" : functionName === "entwicklungs" ? "development" : functionName} leiter${suffix ?? ""}`)
|
|
50
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
51
|
+
}
|
|
44
52
|
function hasTerm(title, term) { return ` ${words(title)} `.includes(` ${words(term)} `); }
|
|
45
53
|
function senior(title) {
|
|
46
|
-
|
|
54
|
+
title = words(title);
|
|
55
|
+
return /\b(director|head|chief|ceo|cto|cio|cdo|cmo|chro|cpo|vp|vice president|vorstand|geschäftsführ\w*|(?:fachbereichs|bereichs|abteilungs|ressort|hauptabteilungs)?leiter(?:in)?|(?:fachbereichs|bereichs|abteilungs)?leitung)\b/i.test(title)
|
|
47
56
|
&& !/\b(assistant|assistent\w*|assistenz|deputy|stellvertret\w*|former|ehemalig\w*)\b/i.test(title);
|
|
48
57
|
}
|
|
58
|
+
// A senior role in one clause must not promote an assistant/former role in another.
|
|
59
|
+
export function classifyCompanyRole(title, department) {
|
|
60
|
+
const clauses = title.split(/\s*[|;\n]\s*|,\s*(?=(?:sr\.?\s+|senior\s+)?(?:director|head|chief|deputy|assistant|former|vp|vice president|leiter|fachbereichsleiter)\b)/i);
|
|
61
|
+
const relevant = clauses.filter(clause => senior(clause));
|
|
62
|
+
if (!relevant.length)
|
|
63
|
+
return "seniority_not_verified";
|
|
64
|
+
const reviewTerms = new Set(department.reviewTerms.map(words));
|
|
65
|
+
if (relevant.some(clause => department.terms.some(term => !reviewTerms.has(words(term)) && hasTerm(clause, term))))
|
|
66
|
+
return "match";
|
|
67
|
+
if (relevant.some(clause => department.terms.some(term => hasTerm(clause, term))))
|
|
68
|
+
return "review";
|
|
69
|
+
// Mixed-role headlines need a human decision, never borrow seniority across clauses.
|
|
70
|
+
if (clauses.length > 1 && department.terms.some(term => hasTerm(title, term)))
|
|
71
|
+
return "review";
|
|
72
|
+
return "function_not_verified";
|
|
73
|
+
}
|
|
49
74
|
function seniorRank(title) {
|
|
50
75
|
if (/\b(chief|ceo|cto|cio|cdo|cmo|chro|cpo|vorstand|geschäftsführ\w*)\b/i.test(title))
|
|
51
76
|
return 0;
|
|
@@ -67,6 +92,8 @@ export function canonicalProfile(value) {
|
|
|
67
92
|
export function shortlistCompanyLeads(brief, results) {
|
|
68
93
|
const selected = [];
|
|
69
94
|
const rejected = [];
|
|
95
|
+
const review = [];
|
|
96
|
+
const reviewSeen = new Set();
|
|
70
97
|
const coverage = [];
|
|
71
98
|
const seen = new Set();
|
|
72
99
|
for (const company of brief.companies) {
|
|
@@ -77,15 +104,28 @@ export function shortlistCompanyLeads(brief, results) {
|
|
|
77
104
|
const profile = canonicalProfile(p.profileUrl);
|
|
78
105
|
// Prefer the actual current job over a free-form profile headline.
|
|
79
106
|
const raw = p.rawLocalSalesNavigatorResult;
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
107
|
+
const positions = raw?.currentPositions?.filter(x => String(x.companyId ?? x.companyUrn?.split(":").pop() ?? "") === company.companyId);
|
|
108
|
+
const titles = positions?.length ? positions.map(x => String(x.title ?? "")) : [String(p.title ?? "")];
|
|
109
|
+
const title = titles.find(t => classifyCompanyRole(t, job.department) === "match") ?? titles.find(t => classifyCompanyRole(t, job.department) === "review") ?? titles[0];
|
|
110
|
+
const exactCompany = raw?.currentPositions?.length ? Boolean(positions?.length) : String(p.companyId ?? "") === company.companyId;
|
|
111
|
+
const role = classifyCompanyRole(title, job.department);
|
|
112
|
+
const reason = !profile ? "invalid_profile" : !exactCompany ? "company_not_verified" : role !== "match" && role !== "review" ? role : null;
|
|
84
113
|
if (reason) {
|
|
85
114
|
rejected.push({ companyId: company.companyId, profileUrl: p.profileUrl, reason });
|
|
86
115
|
continue;
|
|
87
116
|
}
|
|
88
|
-
|
|
117
|
+
const sourceCompanyName = positions?.find(p => p.title === title)?.companyName ?? p.companyName ?? "";
|
|
118
|
+
const employerNameDiffers = Boolean(sourceCompanyName) && words(String(sourceCompanyName)) !== words(company.name);
|
|
119
|
+
const row = { profileUrl: profile, fullName: p.fullName ?? "", title, companyId: company.companyId, companyName: company.name, sourceCompanyName, department: job.department.name, location: p.location ?? "", sourceQueryUrl: job.queryUrl, observedAt: p.timestamp ?? "" };
|
|
120
|
+
if (role === "review" || employerNameDiffers) {
|
|
121
|
+
const key = `${company.companyId}:${job.department.name}:${profile}`;
|
|
122
|
+
if (!reviewSeen.has(key)) {
|
|
123
|
+
reviewSeen.add(key);
|
|
124
|
+
review.push({ ...row, reason: employerNameDiffers ? "employer_name_differs" : "ambiguous_role" });
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
rows.push(row);
|
|
89
129
|
}
|
|
90
130
|
return rows.sort((a, b) => seniorRank(String(a.title)) - seniorRank(String(b.title)) || String(a.profileUrl).localeCompare(String(b.profileUrl)));
|
|
91
131
|
});
|
|
@@ -107,14 +147,14 @@ export function shortlistCompanyLeads(brief, results) {
|
|
|
107
147
|
}
|
|
108
148
|
}
|
|
109
149
|
selected.push(...local);
|
|
110
|
-
coverage.push({ companyName: company.name, companyId: company.companyId ?? null, selected: local.length, ceiling: company.maxContacts,
|
|
150
|
+
coverage.push({ companyName: company.name, companyId: company.companyId ?? null, selected: local.length, reviewRequired: review.filter(p => p.companyId === company.companyId).length, ceiling: company.maxContacts,
|
|
111
151
|
status: !company.companyId ? "unresolved_company" : jobs.some(j => !results[j.key]) ? "pending" : local.length ? "review_ready" : "no_verified_matches",
|
|
112
152
|
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
153
|
}
|
|
114
|
-
return { selected, rejected, coverage, outreachStarted: false, emailEnrichmentStarted: false };
|
|
154
|
+
return { selected, review, rejected, coverage, outreachStarted: false, emailEnrichmentStarted: false };
|
|
115
155
|
}
|
|
116
156
|
export function companyLeadCsv(rows) {
|
|
117
|
-
const columns = ["companyName", "companyId", "fullName", "title", "department", "profileUrl", "location", "sourceQueryUrl", "observedAt"];
|
|
157
|
+
const columns = ["companyName", "companyId", "fullName", "title", "department", "profileUrl", "location", "sourceQueryUrl", "observedAt", "sourceCompanyName", "reason"];
|
|
118
158
|
const escape = (value) => { let s = String(value ?? ""); if (/^[=+@\-\t\r]/.test(s))
|
|
119
159
|
s = "'" + s; return `"${s.replace(/"/g, '""')}"`; };
|
|
120
160
|
return [columns.join(","), ...rows.map(r => columns.map(c => escape(r[c])).join(","))].join("\n") + "\n";
|
|
@@ -153,7 +193,7 @@ async function runCompanyResearchLocked(input) {
|
|
|
153
193
|
throw e;
|
|
154
194
|
}
|
|
155
195
|
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; };
|
|
196
|
+
const publish = async () => { const report = shortlistCompanyLeads(brief, state.results); await save(path.join(outDir, "contacts.csv"), companyLeadCsv(report.selected)); await save(path.join(outDir, "review.csv"), companyLeadCsv(report.review)); await save(path.join(outDir, "coverage.json"), JSON.stringify(report, null, 2)); return report; };
|
|
157
197
|
let performed = 0;
|
|
158
198
|
try {
|
|
159
199
|
for (const job of planCompanySearches(brief)) {
|
|
@@ -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