salesprompter-cli 0.1.77 → 0.1.79
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 +12 -0
- package/dist/cli.js +45 -3
- package/dist/company-leads.js +66 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,10 +44,22 @@ For headless or automation use, generate a CLI token in the app and run `salespr
|
|
|
44
44
|
|
|
45
45
|
The checkpoint, shortlist and review exports refresh after every completed search, so long runs expose saved progress immediately. Company labels with punctuation, such as `(TKMS)`, are escaped safely in search queries.
|
|
46
46
|
|
|
47
|
+
Use `--report-only` to refresh saved reports without contacting LinkedIn. The workspace-bound checkpoint is still checked. `progress` separates unique shortlisted people, review rows, review people, review-only people, pending searches, partial/unknown coverage and unresolved companies. `collectionComplete` is false while any coverage gap remains; `nextActions` explains what is left. Unrestricted geography is labeled explicitly.
|
|
48
|
+
|
|
49
|
+
For a manually verified legal-name alias, add `verifiedEmployerAliases: [{ "name": "Exact source employer name", "evidenceUrl": "https://official.example/legal" }]` to that company. The CLI records the evidence link but does not independently verify its contents. Aliases never bypass the numeric current-company ID check and are never inferred from similar names. Changing the brief requires a new output directory; no existing review rows are silently promoted.
|
|
50
|
+
|
|
47
51
|
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.
|
|
48
52
|
|
|
49
53
|
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.
|
|
50
54
|
|
|
55
|
+
For a name-only list, the executable end-to-end research mode is:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
salesprompter leads:at-companies --brief companies.json --out-dir ./research --resolve-companies --all
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The CLI first collects prepared IDs, then resolves missing IDs through paginated Account Search and immediately collects their contacts. It accepts only one exact-name identity from a fully read result set of at most 1,000 companies; fuzzy, shared-ID, oversized and ambiguous matches remain unresolved. Resolutions and misses are saved in the workspace-bound checkpoint and `company-resolutions.json`, without editing the input brief. Repeat the command to resume; use a new output directory for intentionally retrying saved identity misses. `--all` removes the invocation's job-count limit, not LinkedIn pacing or per-function candidate limits. A working LinkedIn session is required; the Codex browser relay still requires a connected browser worker when used.
|
|
62
|
+
|
|
51
63
|
```json
|
|
52
64
|
{
|
|
53
65
|
"companies": [
|
package/dist/cli.js
CHANGED
|
@@ -15141,12 +15141,15 @@ program
|
|
|
15141
15141
|
.requiredOption("--brief <path>", "JSON company list with verified company IDs and optional role criteria")
|
|
15142
15142
|
.option("--out-dir <path>", "Private output directory and resume checkpoint", "./company-leads")
|
|
15143
15143
|
.option("--max-searches <number>", "Maximum company/function searches this invocation", "12")
|
|
15144
|
+
.option("--report-only", "Refresh saved research reports without contacting LinkedIn", false)
|
|
15145
|
+
.option("--all", "Process all pending company/function searches in this invocation", false)
|
|
15146
|
+
.option("--resolve-companies", "Resolve unique exact company names and collect their contacts in the same run", false)
|
|
15144
15147
|
.option("--browser-relay-port <number>", "Use the signed-in Codex browser through a loopback relay")
|
|
15145
15148
|
.option("--dry-run", "Preview targeting and unresolved companies without network calls", false)
|
|
15146
15149
|
.action(async (options) => {
|
|
15147
15150
|
const brief = await readJsonFile(path.resolve(options.brief), CompanyBriefSchema);
|
|
15148
15151
|
const jobs = planCompanySearches(brief);
|
|
15149
|
-
const maxSearches = z.coerce.number().int().min(1).max(10000).parse(options.maxSearches);
|
|
15152
|
+
const maxSearches = options.all ? Number.MAX_SAFE_INTEGER : z.coerce.number().int().min(1).max(10000).parse(options.maxSearches);
|
|
15150
15153
|
if (options.dryRun) {
|
|
15151
15154
|
printOutput({ status: "ok", dryRun: true, searches: jobs, unresolvedCompanies: brief.companies.filter(c => !c.companyId), maxSearches, outreachStarted: false, emailEnrichmentStarted: false });
|
|
15152
15155
|
return;
|
|
@@ -15155,12 +15158,17 @@ program
|
|
|
15155
15158
|
const orgId = session.user.orgId;
|
|
15156
15159
|
if (!orgId)
|
|
15157
15160
|
throw new Error("Choose a Salesprompter workspace before company research.");
|
|
15161
|
+
if (options.reportOnly) {
|
|
15162
|
+
const report = await runCompanyResearch({ brief, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches: 0, search: async () => { throw new Error("Report-only cannot collect leads."); } });
|
|
15163
|
+
printOutput({ status: "ok", reportOnly: true, progress: report.progress, nextActions: report.nextActions, output: report.output, outreachStarted: false, emailEnrichmentStarted: false });
|
|
15164
|
+
return;
|
|
15165
|
+
}
|
|
15158
15166
|
const port = options.browserRelayPort == null ? null : z.coerce.number().int().min(1).max(65535).parse(options.browserRelayPort);
|
|
15159
15167
|
const relay = port == null ? null : await createLocalAccountSearchBrowserRelay(port);
|
|
15160
15168
|
const config = relay ? null : await readLinkedInDirectLookupConfig();
|
|
15161
15169
|
const recoverSession = createSessionRecovery({
|
|
15162
15170
|
initial: config,
|
|
15163
|
-
isAuthError: error => error instanceof LocalSalesNavigatorHttpError && (error.status === 401 || error.status === 403),
|
|
15171
|
+
isAuthError: error => error instanceof CliImportCompanySessionError || error instanceof LocalSalesNavigatorHttpError && (error.status === 401 || error.status === 403),
|
|
15164
15172
|
refresh: async () => relay || shouldDisableLinkedInDirectLookupAutodiscovery() ? null : readLocalLinkedInExtensionDirectLookupConfig(),
|
|
15165
15173
|
sameSession: (a, b) => a?.cookie === b?.cookie && a?.csrfToken === b?.csrfToken,
|
|
15166
15174
|
beforeRetry: async () => {
|
|
@@ -15171,6 +15179,40 @@ program
|
|
|
15171
15179
|
let startedSearches = 0;
|
|
15172
15180
|
try {
|
|
15173
15181
|
const report = await runCompanyResearch({ brief, outDir: path.resolve(options.outDir), scope: `${session.apiBaseUrl}:${orgId}`, maxSearches,
|
|
15182
|
+
resolveCompany: !options.resolveCompanies ? undefined : async (company) => {
|
|
15183
|
+
if (startedSearches++ > 0)
|
|
15184
|
+
await delay(randomIntegerBetween(5000, 8000));
|
|
15185
|
+
writeProgress(`Resolving ${company.name}`);
|
|
15186
|
+
const queryUrl = buildCliImportAccountSearchUrl(company.name);
|
|
15187
|
+
const response = await recoverSession(activeConfig => fetchCliImportSalesNavigatorJson({ url: queryUrl, config: activeConfig, browserRelay: relay, timeoutMs: 30000, label: `Company identity for ${company.name}` }));
|
|
15188
|
+
if (response.status !== 200 || !response.body)
|
|
15189
|
+
throw new Error(`Company identity search failed (${response.status}).`);
|
|
15190
|
+
const elements = extractLocalSalesNavigatorElements(response.body);
|
|
15191
|
+
const total = extractLocalSalesNavigatorTotalResults(response.body);
|
|
15192
|
+
// A truncated or unknown result set cannot establish a unique identity.
|
|
15193
|
+
if (total == null || total > 1000)
|
|
15194
|
+
return null;
|
|
15195
|
+
for (let start = 25; start < total; start += 25) {
|
|
15196
|
+
await delay(randomIntegerBetween(5000, 8000));
|
|
15197
|
+
const pageUrl = queryUrl.replace(/([?&])start=\d+/, `$1start=${start}`);
|
|
15198
|
+
const page = await recoverSession(activeConfig => fetchCliImportSalesNavigatorJson({ url: pageUrl, config: activeConfig, browserRelay: relay, timeoutMs: 30000, label: `Company identity for ${company.name}` }));
|
|
15199
|
+
if (page.status !== 200 || !page.body)
|
|
15200
|
+
throw new Error(`Company identity page failed (${page.status}).`);
|
|
15201
|
+
const rows = extractLocalSalesNavigatorElements(page.body);
|
|
15202
|
+
if (!rows.length)
|
|
15203
|
+
return null;
|
|
15204
|
+
elements.push(...rows);
|
|
15205
|
+
}
|
|
15206
|
+
if (elements.length < total)
|
|
15207
|
+
return null;
|
|
15208
|
+
const accounts = elements.map(element => normalizeLocalSalesNavigatorAccount(element, queryUrl)).filter(Boolean);
|
|
15209
|
+
const exact = accounts.filter(account => normalizeLooseMatchText(String(account.companyName ?? "")) === normalizeLooseMatchText(company.name));
|
|
15210
|
+
const ids = new Set(exact.map(account => String(account.companyId ?? "")).filter(id => /^[1-9]\d*$/.test(id)));
|
|
15211
|
+
if (ids.size !== 1)
|
|
15212
|
+
return null;
|
|
15213
|
+
const companyId = [...ids][0];
|
|
15214
|
+
return { companyId, companyName: company.name, evidenceUrl: `https://www.linkedin.com/sales/company/${companyId}` };
|
|
15215
|
+
},
|
|
15174
15216
|
search: async (job) => {
|
|
15175
15217
|
if (startedSearches++ > 0)
|
|
15176
15218
|
await delay(randomIntegerBetween(5000, 8000));
|
|
@@ -15187,7 +15229,7 @@ program
|
|
|
15187
15229
|
});
|
|
15188
15230
|
},
|
|
15189
15231
|
});
|
|
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 });
|
|
15232
|
+
printOutput({ status: report.status, selected: report.selected.length, reviewRequired: report.review.length, progress: report.progress, nextActions: report.nextActions, completedSearches: report.completedSearches, totalSearches: report.totalSearches, coverage: report.coverage, output: report.output, resumable: true, outreachStarted: false, emailEnrichmentStarted: false });
|
|
15191
15233
|
}
|
|
15192
15234
|
finally {
|
|
15193
15235
|
await relay?.close();
|
package/dist/company-leads.js
CHANGED
|
@@ -14,6 +14,7 @@ export const defaultDepartments = [
|
|
|
14
14
|
const companySchema = z.object({
|
|
15
15
|
name: z.string().trim().min(1),
|
|
16
16
|
companyId: z.string().regex(/^[1-9]\d*$/).optional(),
|
|
17
|
+
verifiedEmployerAliases: z.array(z.object({ name: z.string().trim().min(1), evidenceUrl: z.url() }).strict()).optional(),
|
|
17
18
|
maxContacts: z.number().int().min(1).max(100).default(20),
|
|
18
19
|
}).strict();
|
|
19
20
|
export const CompanyBriefSchema = z.object({
|
|
@@ -115,8 +116,9 @@ export function shortlistCompanyLeads(brief, results) {
|
|
|
115
116
|
continue;
|
|
116
117
|
}
|
|
117
118
|
const sourceCompanyName = positions?.find(p => p.title === title)?.companyName ?? p.companyName ?? "";
|
|
118
|
-
const
|
|
119
|
-
const
|
|
119
|
+
const alias = company.verifiedEmployerAliases?.find(a => words(a.name) === words(String(sourceCompanyName)));
|
|
120
|
+
const employerNameDiffers = Boolean(sourceCompanyName) && words(String(sourceCompanyName)) !== words(company.name) && !alias;
|
|
121
|
+
const row = { profileUrl: profile, fullName: p.fullName ?? "", title, companyId: company.companyId, companyName: company.name, sourceCompanyName, employerAliasEvidence: alias?.evidenceUrl ?? "", department: job.department.name, location: p.location ?? "", sourceQueryUrl: job.queryUrl, observedAt: p.timestamp ?? "" };
|
|
120
122
|
if (role === "review" || employerNameDiffers) {
|
|
121
123
|
const key = `${company.companyId}:${job.department.name}:${profile}`;
|
|
122
124
|
if (!reviewSeen.has(key)) {
|
|
@@ -151,10 +153,32 @@ export function shortlistCompanyLeads(brief, results) {
|
|
|
151
153
|
status: !company.companyId ? "unresolved_company" : jobs.some(j => !results[j.key]) ? "pending" : local.length ? "review_ready" : "no_verified_matches",
|
|
152
154
|
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 })) });
|
|
153
155
|
}
|
|
154
|
-
|
|
156
|
+
const jobs = planCompanySearches(brief);
|
|
157
|
+
const completed = jobs.filter(j => results[j.key]);
|
|
158
|
+
const incomplete = completed.filter(j => results[j.key].totalResults != null && results[j.key].people.length < results[j.key].totalResults);
|
|
159
|
+
const unknown = completed.filter(j => results[j.key].totalResults == null);
|
|
160
|
+
const selectedIds = new Set(selected.map(p => String(p.profileUrl)));
|
|
161
|
+
const reviewIds = new Set(review.map(p => String(p.profileUrl)));
|
|
162
|
+
const unresolved = brief.companies.filter(c => !c.companyId);
|
|
163
|
+
const progress = {
|
|
164
|
+
selectedPeople: selectedIds.size, reviewRows: review.length, reviewPeople: reviewIds.size,
|
|
165
|
+
reviewOnlyPeople: [...reviewIds].filter(id => !selectedIds.has(id)).length,
|
|
166
|
+
candidateRows: completed.reduce((n, j) => n + results[j.key].people.length, 0),
|
|
167
|
+
completedSearches: completed.length, totalSearches: jobs.length, remainingSearches: jobs.length - completed.length,
|
|
168
|
+
partialSearches: incomplete.length, unknownCoverageSearches: unknown.length, unresolvedCompanies: unresolved.length,
|
|
169
|
+
collectionComplete: completed.length === jobs.length && !unresolved.length && !incomplete.length && !unknown.length,
|
|
170
|
+
geography: brief.regionIds.length ? "explicit_regions" : "unrestricted",
|
|
171
|
+
};
|
|
172
|
+
const nextActions = [
|
|
173
|
+
...(progress.remainingSearches ? ["Resume the unchanged brief and output directory to collect pending searches."] : []),
|
|
174
|
+
...(unresolved.length ? ["Verify unresolved company identities; use a new output directory after changing the brief."] : []),
|
|
175
|
+
...(incomplete.length || unknown.length ? ["Review partial or unknown search coverage before claiming exhaustive collection."] : []),
|
|
176
|
+
...(review.length ? ["Review roles and employer names separately. Add only evidence-backed verifiedEmployerAliases; exact company ID checks still apply."] : []),
|
|
177
|
+
];
|
|
178
|
+
return { selected, review, rejected, coverage, progress, nextActions, outreachStarted: false, emailEnrichmentStarted: false };
|
|
155
179
|
}
|
|
156
180
|
export function companyLeadCsv(rows) {
|
|
157
|
-
const columns = ["companyName", "companyId", "fullName", "title", "department", "profileUrl", "location", "sourceQueryUrl", "observedAt", "sourceCompanyName", "reason"];
|
|
181
|
+
const columns = ["companyName", "companyId", "fullName", "title", "department", "profileUrl", "location", "sourceQueryUrl", "observedAt", "sourceCompanyName", "reason", "employerAliasEvidence"];
|
|
158
182
|
const escape = (value) => { let s = String(value ?? ""); if (/^[=+@\-\t\r]/.test(s))
|
|
159
183
|
s = "'" + s; return `"${s.replace(/"/g, '""')}"`; };
|
|
160
184
|
return [columns.join(","), ...rows.map(r => columns.map(c => escape(r[c])).join(","))].join("\n") + "\n";
|
|
@@ -193,18 +217,45 @@ async function runCompanyResearchLocked(input) {
|
|
|
193
217
|
throw e;
|
|
194
218
|
}
|
|
195
219
|
const save = async (file, value) => { await writeFile(file + ".tmp", value, { mode: 0o600 }); await chmod(file + ".tmp", 0o600); await rename(file + ".tmp", file); };
|
|
196
|
-
const
|
|
220
|
+
const effectiveBrief = () => CompanyBriefSchema.parse({ ...brief, companies: brief.companies.map((company, index) => {
|
|
221
|
+
const resolution = state.resolutions?.[String(index)];
|
|
222
|
+
return !company.companyId && resolution ? { ...company, companyId: resolution.companyId } : company;
|
|
223
|
+
}) });
|
|
224
|
+
const publish = async () => { const report = shortlistCompanyLeads(effectiveBrief(), 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)); await save(path.join(outDir, "company-resolutions.json"), JSON.stringify(state.resolutions ?? {}, null, 2)); return report; };
|
|
197
225
|
let performed = 0;
|
|
198
226
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
227
|
+
// Known IDs run first; an unresolved identity cannot block already prepared work.
|
|
228
|
+
const collect = async () => {
|
|
229
|
+
for (const job of planCompanySearches(effectiveBrief())) {
|
|
230
|
+
if (state.results[job.key])
|
|
231
|
+
continue;
|
|
232
|
+
if (performed >= input.maxSearches)
|
|
233
|
+
break;
|
|
234
|
+
state.results[job.key] = await input.search(job);
|
|
235
|
+
performed++;
|
|
236
|
+
await save(checkpoint, JSON.stringify(state));
|
|
237
|
+
await publish();
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
await collect();
|
|
241
|
+
if (input.resolveCompany) {
|
|
242
|
+
let resolutionsPerformed = 0;
|
|
243
|
+
for (const [index, company] of brief.companies.entries()) {
|
|
244
|
+
if (performed >= input.maxSearches || resolutionsPerformed >= input.maxSearches)
|
|
245
|
+
break;
|
|
246
|
+
if (company.companyId || Object.hasOwn(state.resolutions ?? {}, String(index)))
|
|
247
|
+
continue;
|
|
248
|
+
const resolution = await input.resolveCompany(company);
|
|
249
|
+
resolutionsPerformed++;
|
|
250
|
+
if (resolution && (!/^[1-9]\d*$/.test(resolution.companyId) || words(resolution.companyName) !== words(company.name)))
|
|
251
|
+
throw new Error("Company resolver returned a non-exact identity.");
|
|
252
|
+
const occupied = new Set(effectiveBrief().companies.map(c => c.companyId).filter(Boolean));
|
|
253
|
+
state.resolutions ??= {};
|
|
254
|
+
state.resolutions[String(index)] = resolution && !occupied.has(resolution.companyId) ? resolution : null;
|
|
255
|
+
await save(checkpoint, JSON.stringify(state));
|
|
256
|
+
await publish();
|
|
257
|
+
await collect();
|
|
258
|
+
}
|
|
208
259
|
}
|
|
209
260
|
}
|
|
210
261
|
catch (e) {
|
|
@@ -212,5 +263,5 @@ async function runCompanyResearchLocked(input) {
|
|
|
212
263
|
throw e;
|
|
213
264
|
}
|
|
214
265
|
const report = await publish();
|
|
215
|
-
return { status: "ok", ...report, completedSearches: Object.keys(state.results).length, totalSearches: planCompanySearches(
|
|
266
|
+
return { status: "ok", ...report, completedSearches: Object.keys(state.results).length, totalSearches: planCompanySearches(effectiveBrief()).length, output: outDir, resumable: true };
|
|
216
267
|
}
|
package/package.json
CHANGED