salesprompter-cli 0.1.75 → 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 +7 -1
- package/dist/cli.js +1 -1
- package/dist/company-leads.js +58 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,7 +67,13 @@ The default six functions are Digital Marketing & CRM, Digital Product & UX, Sof
|
|
|
67
67
|
|
|
68
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
69
|
|
|
70
|
-
|
|
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.
|
|
71
77
|
|
|
72
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.
|
|
73
79
|
|
package/dist/cli.js
CHANGED
|
@@ -15187,7 +15187,7 @@ program
|
|
|
15187
15187
|
});
|
|
15188
15188
|
},
|
|
15189
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 });
|
|
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 });
|
|
15191
15191
|
}
|
|
15192
15192
|
finally {
|
|
15193
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)) {
|
package/package.json
CHANGED