salesprompter-cli 0.1.72 → 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 +33 -0
- package/dist/affiliate-copy.js +49 -0
- package/dist/cli.js +93 -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
|
|
@@ -177,6 +208,8 @@ the app's CLI imports view.
|
|
|
177
208
|
- If every collected profile is known or excluded, no audience or Instantly campaign is created. A stale Sales Navigator session also fails before persistence with a safe retry instruction.
|
|
178
209
|
- Affiliate outreach uses Hunter first, requires Hunter's `valid` verdict before import, and sends recovered addresses through Hunter Email Verifier before creating a draft Instantly campaign. Catch-all, disposable, webmail, invalid, unknown, claimed, and unresolved addresses are excluded. Instantly verification remains enabled as a second gate.
|
|
179
210
|
- Affiliate preparation uses exactly three emails with three observable variants per step; `affiliate:regenerate-sequence` rebuilds and syncs them through the Salesprompter app.
|
|
211
|
+
- For reviewed Gojiberry copy, use `affiliate:copy <run-id> --out review.json --html preview.html`. This creates nine distinct variants without changing the campaign. Subjects, greetings, value lines, CTAs, link labels, and opt-outs use native Instantly spintax; step URLs keep their exact tracking parameters. Edit the structured `draft`, then run `--draft review.json --out reviewed.json` to validate it again. Apply only an unchanged saved review with `--apply reviewed.json`; active campaigns also require `--allow-active`. Newer Salesprompter or Instantly edits invalidate old reviews. Sending limits and audience membership are untouched.
|
|
212
|
+
- The copy pack keeps a concise `(paid link)` disclosure. Wording and spintax cannot guarantee inbox delivery. Preview the actual email in Instantly, especially with plain-text sending enabled. Semantic relevance, factual claims, reading level and final mobile wrapping still need human review.
|
|
180
213
|
- `affiliate:list` shows safe workspace summaries. `affiliate:analytics` shows aggregate and step/variant outcomes without exposing lead data; unsupported per-step meeting and won fields render as `n/a`.
|
|
181
214
|
- `affiliate:run` defaults to `--timing-mode custom`, so its daily limit and weekday sending window are honored. Use `--timing-mode auto` only when capacity-derived scheduling is intended.
|
|
182
215
|
- `affiliate:enrich` starts durable email recovery and adds only newly found addresses to an existing campaign. It returns a background-processing status for large audiences instead of blocking the terminal. Pass `--source-audience-run-id` to merge a newer compatible audience into that campaign without creating a duplicate campaign.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/);
|
|
3
|
+
export const AffiliateCopyReviewSchema = z.object({
|
|
4
|
+
status: z.literal("ok"),
|
|
5
|
+
applied: z.boolean(),
|
|
6
|
+
runId: z.string().uuid(),
|
|
7
|
+
campaignId: z.string(),
|
|
8
|
+
campaignName: z.string().nullable(),
|
|
9
|
+
sourceHash: hash,
|
|
10
|
+
remoteSequenceHash: hash,
|
|
11
|
+
reviewHash: hash,
|
|
12
|
+
draft: z
|
|
13
|
+
.object({ version: z.literal(1), steps: z.array(z.unknown()).length(3) })
|
|
14
|
+
.passthrough(),
|
|
15
|
+
sequence: z.array(z.unknown()).length(3),
|
|
16
|
+
previews: z
|
|
17
|
+
.array(z.object({
|
|
18
|
+
step: z.number(),
|
|
19
|
+
variant: z.string(),
|
|
20
|
+
angle: z.string(),
|
|
21
|
+
maxWords: z.number(),
|
|
22
|
+
combinations: z.number(),
|
|
23
|
+
examples: z
|
|
24
|
+
.array(z.object({
|
|
25
|
+
subject: z.string(),
|
|
26
|
+
body: z.string(),
|
|
27
|
+
html: z.string(),
|
|
28
|
+
}))
|
|
29
|
+
.min(1),
|
|
30
|
+
}))
|
|
31
|
+
.length(9),
|
|
32
|
+
warnings: z.array(z.string()),
|
|
33
|
+
sources: z.array(z.string()),
|
|
34
|
+
});
|
|
35
|
+
const escapeHtml = (value) => value.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
36
|
+
/** Render escaped data, never trusted remote HTML; 360 px cards emulate a phone. */
|
|
37
|
+
export function renderAffiliateCopyReview(review) {
|
|
38
|
+
return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><title>Gojiberry copy review</title><style>body{font:16px/1.55 system-ui;background:#f3f5f7;color:#17222f;margin:24px}main{max-width:1200px;margin:auto}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr));gap:24px}article{max-width:360px;background:white;border:1px solid #d9e1e8;border-radius:12px;padding:20px;overflow-wrap:anywhere}h1{font-size:24px}h2{font-size:18px}small{color:#536171}p{margin:0 0 20px}a{color:#17655b}summary{cursor:pointer}section{margin-top:20px}</style><main><h1>${escapeHtml(review.campaignName ?? "Affiliate copy review")}</h1><p>9 variants · native Instantly spintax · review only${review.applied ? " (this review was applied)" : " — no campaign changes"}</p><div class="grid">${review.previews
|
|
39
|
+
.map((preview) => `<article><small>STEP ${preview.step} · ${escapeHtml(preview.variant)} · ${escapeHtml(preview.angle)}</small><h2>${escapeHtml(preview.examples[0].subject)}</h2><small>Up to ${preview.maxWords} words with fallback values · ${preview.combinations.toLocaleString("en-US")} combinations</small>${preview.examples
|
|
40
|
+
.map((example, index) => `${index ? `<details><summary>${index === 1 ? "Missing-name fallback" : "Long-name sample"}</summary>` : ""}<section>${example.body
|
|
41
|
+
.split("\n\n")
|
|
42
|
+
.map((line) => {
|
|
43
|
+
const link = line.match(/^\[([^\]]+)\]\((https:\/\/[^\s]+)\) \(paid link\)$/);
|
|
44
|
+
return `<p>${link ? `<a href="${escapeHtml(link[2])}" rel="noreferrer sponsored">${escapeHtml(link[1])}</a> (paid link)` : escapeHtml(line)}</p>`;
|
|
45
|
+
})
|
|
46
|
+
.join("")}</section>${index ? "</details>" : ""}`)
|
|
47
|
+
.join("")}</article>`)
|
|
48
|
+
.join("")}</div><h2>Review notes</h2><ul>${review.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join("")}</ul><p>Changing this file does not change the saved JSON review. Preview edited drafts again before applying.</p></main></html>`;
|
|
49
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,8 @@ import { createClient } from "@supabase/supabase-js";
|
|
|
14
14
|
import pg from "pg";
|
|
15
15
|
import { Command } from "commander";
|
|
16
16
|
import { z } from "zod";
|
|
17
|
+
import { AffiliateCopyReviewSchema, renderAffiliateCopyReview } from "./affiliate-copy.js";
|
|
18
|
+
import { CompanyBriefSchema, planCompanySearches, runCompanyResearch } from "./company-leads.js";
|
|
17
19
|
import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, readAuthSession, requireAuthSession, shouldBypassAuth, verifySession, writeAuthSession } from "./auth.js";
|
|
18
20
|
import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
|
|
19
21
|
import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
|
|
@@ -705,6 +707,7 @@ const cliPacks = [
|
|
|
705
707
|
commands: [
|
|
706
708
|
"leads:discover",
|
|
707
709
|
"leads:collect",
|
|
710
|
+
"leads:at-companies",
|
|
708
711
|
"leads:download",
|
|
709
712
|
"leads:enrich-import",
|
|
710
713
|
"search:run",
|
|
@@ -730,6 +733,7 @@ const cliPacks = [
|
|
|
730
733
|
"affiliate:finish",
|
|
731
734
|
"affiliate:analytics",
|
|
732
735
|
"affiliate:regenerate-sequence",
|
|
736
|
+
"affiliate:copy",
|
|
733
737
|
"affiliate:enrich",
|
|
734
738
|
"affiliate:activate",
|
|
735
739
|
"affiliate:launch",
|
|
@@ -774,6 +778,7 @@ const helpVisibleCommandNames = new Set([
|
|
|
774
778
|
"auth:whoami",
|
|
775
779
|
"llm:ready",
|
|
776
780
|
"leads:download",
|
|
781
|
+
"leads:at-companies",
|
|
777
782
|
"contacts:find-linkedin-urls",
|
|
778
783
|
"companies:find-linkedin-urls",
|
|
779
784
|
"contacts:process-emails",
|
|
@@ -791,6 +796,7 @@ const helpVisibleCommandNames = new Set([
|
|
|
791
796
|
"affiliate:finish",
|
|
792
797
|
"affiliate:analytics",
|
|
793
798
|
"affiliate:regenerate-sequence",
|
|
799
|
+
"affiliate:copy",
|
|
794
800
|
"affiliate:enrich",
|
|
795
801
|
"affiliate:activate",
|
|
796
802
|
"affiliate:launch",
|
|
@@ -15128,6 +15134,52 @@ program
|
|
|
15128
15134
|
.action(async (options) => {
|
|
15129
15135
|
printOutput(await runSalesNavigatorPeopleCollectCommand(options));
|
|
15130
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
|
+
});
|
|
15131
15183
|
program
|
|
15132
15184
|
.command("leads:download")
|
|
15133
15185
|
.alias("leads:export-csv")
|
|
@@ -15327,6 +15379,45 @@ program
|
|
|
15327
15379
|
const analytics = await getAffiliateOutreachAnalyticsViaApp(session, z.string().uuid().parse(runId));
|
|
15328
15380
|
printOutput(formatAffiliateAnalyticsForOutput(analytics));
|
|
15329
15381
|
});
|
|
15382
|
+
program
|
|
15383
|
+
.command("affiliate:copy <run-id>")
|
|
15384
|
+
.description("Review 9 short variants with subject, body and link-text spintax; apply only a saved review.")
|
|
15385
|
+
.option("--draft <file>", "Preview a custom structured draft (or an edited review's draft)")
|
|
15386
|
+
.option("--out <file>", "Save the complete JSON review")
|
|
15387
|
+
.option("--html <file>", "Save a mobile-width HTML preview")
|
|
15388
|
+
.option("--apply <review-file>", "Apply a saved, unchanged review after server-side stale checks")
|
|
15389
|
+
.option("--allow-active", "Allow the reviewed copy to replace an active campaign's sequence")
|
|
15390
|
+
.action(async (runId, options) => {
|
|
15391
|
+
const audienceRunId = z.string().uuid().parse(runId);
|
|
15392
|
+
if (options.apply && options.draft)
|
|
15393
|
+
throw new Error("Use --draft to preview edits first, then --apply the saved review.");
|
|
15394
|
+
if (options.allowActive && !options.apply)
|
|
15395
|
+
throw new Error("--allow-active is only valid with --apply.");
|
|
15396
|
+
if (options.out && options.html && path.resolve(options.out) === path.resolve(options.html))
|
|
15397
|
+
throw new Error("JSON and HTML output paths must differ.");
|
|
15398
|
+
const request = { apply: false };
|
|
15399
|
+
if (options.draft) {
|
|
15400
|
+
const file = JSON.parse(await readFile(path.resolve(options.draft), "utf8"));
|
|
15401
|
+
request.draft = file.draft ?? file;
|
|
15402
|
+
}
|
|
15403
|
+
if (options.apply) {
|
|
15404
|
+
const review = AffiliateCopyReviewSchema.parse(JSON.parse(await readFile(path.resolve(options.apply), "utf8")));
|
|
15405
|
+
if (review.runId !== audienceRunId)
|
|
15406
|
+
throw new Error("The review belongs to a different affiliate run.");
|
|
15407
|
+
if (review.applied)
|
|
15408
|
+
throw new Error("This review was already applied. Preview again before another change.");
|
|
15409
|
+
Object.assign(request, { apply: true, allowActive: Boolean(options.allowActive), draft: review.draft, sourceHash: review.sourceHash, remoteSequenceHash: review.remoteSequenceHash, reviewHash: review.reviewHash });
|
|
15410
|
+
}
|
|
15411
|
+
const session = await requireAuthSession();
|
|
15412
|
+
const { value } = await fetchCliJson(session, currentSession => fetch(`${currentSession.apiBaseUrl}/api/cli/affiliate-outreach/${encodeURIComponent(audienceRunId)}/copy`, {
|
|
15413
|
+
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${currentSession.accessToken}` }, body: JSON.stringify(request)
|
|
15414
|
+
}), AffiliateCopyReviewSchema);
|
|
15415
|
+
if (options.out)
|
|
15416
|
+
await writeJsonFile(path.resolve(options.out), value);
|
|
15417
|
+
if (options.html)
|
|
15418
|
+
await writeTextFile(path.resolve(options.html), renderAffiliateCopyReview(value));
|
|
15419
|
+
printOutput(options.out ? { status: "ok", applied: value.applied, campaignId: value.campaignId, variants: value.previews.length, maxWords: Math.max(...value.previews.map(v => v.maxWords)), review: path.resolve(options.out), html: options.html ? path.resolve(options.html) : null, warnings: value.warnings, next: value.applied ? "Copy was read back from Instantly; audience and sending limits were not changed." : `Review the file, then: salesprompter affiliate:copy ${audienceRunId} --apply ${JSON.stringify(path.resolve(options.out))} (add --allow-active only if intended)` } : value);
|
|
15420
|
+
});
|
|
15330
15421
|
program
|
|
15331
15422
|
.command("affiliate:regenerate-sequence <run-id>")
|
|
15332
15423
|
.description("Regenerate observable sequence variants, then sync them to Instantly.")
|
|
@@ -19383,5 +19474,7 @@ main()
|
|
|
19383
19474
|
})
|
|
19384
19475
|
.finally(async () => {
|
|
19385
19476
|
await closeGlobalHttpDispatcher();
|
|
19477
|
+
// Flush piped JSON, including large copy reviews, before terminating.
|
|
19478
|
+
await Promise.all([process.stdout, process.stderr].map(stream => new Promise(resolve => stream.write("", () => resolve()))));
|
|
19386
19479
|
process.exit(process.exitCode ?? 0);
|
|
19387
19480
|
});
|
|
@@ -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