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 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
- 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.
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
- 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,
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();
@@ -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) { return text.toLowerCase().normalize("NFKC").replace(/[^\p{L}\p{N}]+/gu, " ").trim(); }
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
- 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)
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 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;
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
- 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 ?? "" });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
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",