indiecrm-cli 0.1.0 → 0.2.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +442 -0
  2. package/CODE_OF_CONDUCT.md +35 -0
  3. package/CONTRIBUTING.md +7 -0
  4. package/README.md +14 -2
  5. package/RESEARCH.md +346 -0
  6. package/SECURITY.md +35 -0
  7. package/dist/affiliate-copy.js +49 -0
  8. package/dist/auth.js +453 -0
  9. package/dist/bigquery.js +199 -0
  10. package/dist/chrome-browser.js +231 -0
  11. package/dist/cli.js +19651 -0
  12. package/dist/company-identity-review.js +78 -0
  13. package/dist/company-leads.js +422 -0
  14. package/dist/company-recovery.js +84 -0
  15. package/dist/deel-outreach.js +469 -0
  16. package/dist/deel-salesnav.js +368 -0
  17. package/dist/direct-path.js +326 -0
  18. package/dist/domain.js +53 -0
  19. package/dist/domainfinder.js +764 -0
  20. package/dist/engine.js +216 -0
  21. package/dist/historical-queries.js +189 -0
  22. package/dist/hunter-emailfinder.js +252 -0
  23. package/dist/icp-templates.js +171 -0
  24. package/dist/indiecrm/commands.js +106 -0
  25. package/dist/indiecrm-cli.js +2 -104
  26. package/dist/instantly.js +136 -0
  27. package/dist/io.js +21 -0
  28. package/dist/leadlists-funnel.js +148 -0
  29. package/dist/linkedin-companies.js +562 -0
  30. package/dist/linkedin-product-details.js +1203 -0
  31. package/dist/linkedin-product-search.js +1081 -0
  32. package/dist/linkedin-products.js +786 -0
  33. package/dist/linkedin-session-contracts.js +3 -0
  34. package/dist/linkedin-session.js +846 -0
  35. package/dist/providers.js +1 -0
  36. package/dist/research-browser-preference.js +37 -0
  37. package/dist/sales-navigator.js +1231 -0
  38. package/dist/salesnav-backfill.js +710 -0
  39. package/dist/sample-data.js +34 -0
  40. package/dist/session-recovery.js +62 -0
  41. package/dist/vendor/salesprompter-shared/extension-session-contracts.js +29 -0
  42. package/dist/vendor/salesprompter-shared/linkedin-session.js +22 -0
  43. package/dist/vendor/salesprompter-shared/phantombuster-contracts.js +16 -0
  44. package/dist/vendor/salesprompter-shared/session-vault-contracts.js +17 -0
  45. package/package.json +73 -14
@@ -0,0 +1,78 @@
1
+ import { companyIdentityKey } from "./company-recovery.js";
2
+ import { createInterface } from "node:readline/promises";
3
+ /** Cancelling a terminal question must reject it so research can release its lock. */
4
+ export function createCompanyReviewPrompt(input = process.stdin, output = process.stderr) {
5
+ const rl = createInterface({ input, output, terminal: true });
6
+ const cancellation = new AbortController();
7
+ const cancel = () => cancellation.abort();
8
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
9
+ rl.on("SIGINT", cancel);
10
+ rl.once("close", cancel);
11
+ for (const signal of signals)
12
+ process.on(signal, cancel);
13
+ return {
14
+ io: {
15
+ write: (text) => { output.write(text); },
16
+ ask: async (question) => {
17
+ try {
18
+ return await rl.question(question, { signal: cancellation.signal });
19
+ }
20
+ catch (error) {
21
+ if (cancellation.signal.aborted)
22
+ throw new Error("Company review cancelled. Saved work is preserved; rerun with the same --out-dir to resume.");
23
+ throw error;
24
+ }
25
+ },
26
+ },
27
+ close: () => {
28
+ for (const signal of signals)
29
+ process.off(signal, cancel);
30
+ rl.close();
31
+ },
32
+ };
33
+ }
34
+ export const terminalText = (text) => text.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, " ").slice(0, 300);
35
+ export async function chooseCompanyIdentity(input) {
36
+ const { targetName, diagnostic, details, io } = input;
37
+ const candidates = [...new Map(diagnostic.candidates.filter(c => /^[1-9]\d*$/.test(c.companyId) && companyIdentityKey(c.name) === companyIdentityKey(targetName)).map(c => [c.companyId, c])).values()];
38
+ io.write(`\nCompany review: ${terminalText(targetName)}\n`);
39
+ if (!candidates.length || candidates.length > 10) {
40
+ io.write("No bounded exact-name choice is available. Inspect recovery.json and use an evidence-backed --review-file; this company remains unresolved.\n");
41
+ return null;
42
+ }
43
+ const verified = [];
44
+ for (const candidate of candidates) {
45
+ const evidence = await details(candidate.companyId);
46
+ if (!evidence || evidence.companyId !== candidate.companyId || evidence.evidenceUrl !== `https://www.linkedin.com/sales/company/${candidate.companyId}` || companyIdentityKey(evidence.name) !== companyIdentityKey(candidate.name))
47
+ continue;
48
+ verified.push(evidence);
49
+ io.write(`\n${verified.length}. ${terminalText(evidence.name)}\n Location: ${terminalText(evidence.location ?? "not provided")}\n Website: ${terminalText(evidence.website ?? "not provided")}\n Evidence: ${terminalText(evidence.evidenceUrl)}\n`);
50
+ }
51
+ if (!verified.length) {
52
+ io.write("No candidate could be verified. Nothing approved.\n");
53
+ return null;
54
+ }
55
+ io.write("\nMatch the location and website to your intended company. No choice is preselected.\n");
56
+ while (true) {
57
+ const answer = (await io.ask("Company number [0 = skip]: ")).trim();
58
+ if (!answer || answer === "0")
59
+ return null;
60
+ if (!/^[1-9]\d*$/.test(answer) || Number(answer) > verified.length) {
61
+ io.write("Enter a listed number, or 0 to skip.\n");
62
+ continue;
63
+ }
64
+ const chosen = verified[Number(answer) - 1];
65
+ const confirm = await io.ask(`Use ${terminalText(chosen.name)} (${terminalText(chosen.location ?? chosen.companyId)}) for ${terminalText(targetName)}? [y/N]: `);
66
+ return /^(y|yes)$/i.test(confirm.trim()) ? chosen : null;
67
+ }
68
+ }
69
+ export function companyResearchNextCommand(input) {
70
+ const quote = (text) => `'${text.replace(/'/g, "'\\''")}'`;
71
+ const scope = (input.companies ?? []).map(name => ` --company ${quote(name)}`).join("");
72
+ const base = `indiecrm leads:at-companies --brief ${quote(input.brief)} --out-dir ${quote(input.outDir)} --browser ${input.browser}${scope}`;
73
+ if (input.unresolved)
74
+ return `${base} --resolve-companies --review --all`;
75
+ if (input.pending)
76
+ return `${base} --all`;
77
+ return null;
78
+ }
@@ -0,0 +1,422 @@
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
+ import { CompanyReviewSchema, companyIdentityKey } from "./company-recovery.js";
7
+ export const defaultDepartments = [
8
+ { name: "Digital Marketing & CRM", terms: ["marketing", "crm", "growth", "communication"], reviewTerms: ["communication"] },
9
+ { name: "Digital Product & UX", terms: ["product", "ux", "design"], reviewTerms: [] },
10
+ { name: "Software Development", terms: ["software", "engineering", "development", "cto", "operational technology"], reviewTerms: ["operational technology"] },
11
+ { name: "IT", terms: ["it", "information technology", "cio", "digital workplace", "chief digital officer", "cdo", "digital", "transformation"], reviewTerms: ["digital", "transformation"] },
12
+ { name: "Data & AI", terms: ["data", "ai", "artificial intelligence", "analytics", "business intelligence"], reviewTerms: [] },
13
+ { name: "HR", terms: ["hr", "human resources", "people", "personal", "chro", "talent"], reviewTerms: [] },
14
+ ];
15
+ const companySchema = z.object({
16
+ name: z.string().trim().min(1),
17
+ companyId: z.string().regex(/^[1-9]\d*$/).optional(),
18
+ verifiedEmployerAliases: z.array(z.object({ name: z.string().trim().min(1), evidenceUrl: z.url() }).strict()).optional(),
19
+ maxContacts: z.number().int().min(1).max(100).default(20),
20
+ }).strict();
21
+ export const CompanyBriefSchema = z.object({
22
+ companies: z.array(companySchema).min(1),
23
+ 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),
24
+ regionIds: z.array(z.string().regex(/^[1-9]\d*$/)).default([]),
25
+ candidatesPerDepartment: z.number().int().min(1).max(100).default(50),
26
+ }).strict().superRefine((brief, ctx) => {
27
+ const ids = brief.companies.flatMap(c => c.companyId ? [c.companyId] : []);
28
+ if (new Set(ids).size !== ids.length)
29
+ ctx.addIssue({ code: "custom", message: "Duplicate company IDs: merge aliases into one target company." });
30
+ const names = brief.departments.map(d => d.name.toLowerCase());
31
+ if (new Set(names).size !== names.length)
32
+ ctx.addIssue({ code: "custom", message: "Department names must be unique." });
33
+ for (const department of brief.departments) {
34
+ if (department.reviewTerms.some(term => !department.terms.some(t => words(t) === words(term))))
35
+ ctx.addIssue({ code: "custom", message: "Review terms must also be included in department search terms." });
36
+ }
37
+ });
38
+ export function planCompanySearches(brief) {
39
+ return brief.companies.filter(c => c.companyId).flatMap(company => brief.departments.map((department, index) => ({
40
+ key: `${company.companyId}:${index}`, company, department,
41
+ queryUrl: buildSalesNavigatorPeopleSearchUrl([
42
+ { type: "CURRENT_COMPANY", values: [{ id: company.companyId, text: company.name, selectionType: "INCLUDED" }] },
43
+ { type: "CURRENT_TITLE", values: department.terms.map(term => ({ text: term, selectionType: "INCLUDED" })) },
44
+ { 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" })) },
45
+ ...(brief.regionIds.length ? [{ type: "REGION", values: brief.regionIds.map(id => ({ id, text: id, selectionType: "INCLUDED" })) }] : []),
46
+ ]),
47
+ })));
48
+ }
49
+ function words(text) {
50
+ return text.toLowerCase().normalize("NFKC")
51
+ .replace(/\b(marketing|personal|entwicklungs|it)leiter(in)?\b/g, (_, functionName, suffix) => `${functionName === "personal" ? "hr" : functionName === "entwicklungs" ? "development" : functionName} leiter${suffix ?? ""}`)
52
+ .replace(/[^\p{L}\p{N}]+/gu, " ").trim();
53
+ }
54
+ function hasTerm(title, term) { return ` ${words(title)} `.includes(` ${words(term)} `); }
55
+ function senior(title) {
56
+ title = words(title);
57
+ 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)
58
+ && !/\b(assistant|assistent\w*|assistenz|deputy|stellvertret\w*|former|ehemalig\w*)\b/i.test(title);
59
+ }
60
+ // A senior role in one clause must not promote an assistant/former role in another.
61
+ export function classifyCompanyRole(title, department) {
62
+ 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);
63
+ const relevant = clauses.filter(clause => senior(clause));
64
+ if (!relevant.length)
65
+ return "seniority_not_verified";
66
+ const reviewTerms = new Set(department.reviewTerms.map(words));
67
+ const contextMatches = (clause) => {
68
+ const t = words(clause);
69
+ if (department.name === "Software Development")
70
+ return /\b(software|cto|developer|developers|devops|frontend|backend|full stack)\b/.test(t);
71
+ if (department.name === "Digital Product & UX")
72
+ return /\b(ux|user experience|digital product|software product|product design|digital design|ui)\b/.test(t) && !/\b(vehicles|mechanical|industrial design|hardware design)\b/.test(t);
73
+ if (department.name === "Data & AI" && /\b(technical data|hydraulic|flight control)\b/.test(t))
74
+ return /\b(analytics|artificial intelligence|machine learning|data science|data engineering)\b/.test(t);
75
+ return true;
76
+ };
77
+ if (relevant.some(clause => contextMatches(clause) && department.terms.some(term => !reviewTerms.has(words(term)) && hasTerm(clause, term))))
78
+ return "match";
79
+ if (relevant.some(clause => department.terms.some(term => hasTerm(clause, term))))
80
+ return "review";
81
+ // Mixed-role headlines need a human decision, never borrow seniority across clauses.
82
+ if (clauses.length > 1 && department.terms.some(term => hasTerm(title, term)))
83
+ return "review";
84
+ return "function_not_verified";
85
+ }
86
+ function seniorRank(title) {
87
+ if (/\b(chief|ceo|cto|cio|cdo|cmo|chro|cpo|vorstand|geschäftsführ\w*)\b/i.test(title))
88
+ return 0;
89
+ if (/\b(vp|vice president|head|leiter\w*|leitung)\b/i.test(title))
90
+ return 1;
91
+ return 2;
92
+ }
93
+ export function canonicalProfile(value) {
94
+ try {
95
+ const u = new URL(value);
96
+ if (!/(^|\.)linkedin\.com$/i.test(u.hostname) || !/^\/(in|sales\/lead)\/[^/]+/.test(u.pathname))
97
+ return null;
98
+ return `https://www.linkedin.com${u.pathname.replace(/\/$/, "")}`;
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ export function shortlistCompanyLeads(brief, results) {
105
+ const selected = [];
106
+ const rejected = [];
107
+ const review = [];
108
+ const reviewSeen = new Set();
109
+ const coverage = [];
110
+ const seen = new Set();
111
+ for (const company of brief.companies) {
112
+ const jobs = planCompanySearches({ ...brief, companies: [company] });
113
+ const buckets = jobs.map(job => {
114
+ const rows = [];
115
+ for (const p of results[job.key]?.people ?? []) {
116
+ const profile = canonicalProfile(p.profileUrl);
117
+ // Prefer the actual current job over a free-form profile headline.
118
+ const raw = p.rawLocalSalesNavigatorResult;
119
+ const positions = raw?.currentPositions?.filter(x => String(x.companyId ?? x.companyUrn?.split(":").pop() ?? "") === company.companyId);
120
+ const titles = positions?.length ? positions.map(x => String(x.title ?? "")) : [String(p.title ?? "")];
121
+ const title = titles.find(t => classifyCompanyRole(t, job.department) === "match") ?? titles.find(t => classifyCompanyRole(t, job.department) === "review") ?? titles[0];
122
+ const exactCompany = raw?.currentPositions?.length ? Boolean(positions?.length) : String(p.companyId ?? "") === company.companyId;
123
+ const role = classifyCompanyRole(title, job.department);
124
+ const reason = !profile ? "invalid_profile" : !exactCompany ? "company_not_verified" : role !== "match" && role !== "review" ? role : null;
125
+ if (reason) {
126
+ rejected.push({ companyId: company.companyId, profileUrl: p.profileUrl, reason });
127
+ continue;
128
+ }
129
+ const sourceCompanyName = positions?.find(p => p.title === title)?.companyName ?? p.companyName ?? "";
130
+ const alias = company.verifiedEmployerAliases?.find(a => words(a.name) === words(String(sourceCompanyName)));
131
+ const employerNameDiffers = Boolean(sourceCompanyName) && words(String(sourceCompanyName)) !== words(company.name) && !alias;
132
+ 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 ?? "" };
133
+ if (role === "review" || employerNameDiffers) {
134
+ const key = `${company.companyId}:${job.department.name}:${profile}`;
135
+ if (!reviewSeen.has(key)) {
136
+ reviewSeen.add(key);
137
+ review.push({ ...row, reason: employerNameDiffers ? "employer_name_differs" : "ambiguous_role", reasons: [...(employerNameDiffers ? ["employer_name_differs"] : []), ...(role === "review" ? ["ambiguous_role"] : [])] });
138
+ }
139
+ continue;
140
+ }
141
+ rows.push(row);
142
+ }
143
+ return rows.sort((a, b) => seniorRank(String(a.title)) - seniorRank(String(b.title)) || String(a.profileUrl).localeCompare(String(b.profileUrl)));
144
+ });
145
+ const local = [];
146
+ // Round-robin selection stops one function consuming the entire company allowance.
147
+ while (local.length < company.maxContacts && buckets.some(b => b.length)) {
148
+ for (const bucket of buckets) {
149
+ let row;
150
+ while ((row = bucket.shift())) {
151
+ const key = String(row.profileUrl);
152
+ if (!seen.has(key)) {
153
+ seen.add(key);
154
+ local.push(row);
155
+ break;
156
+ }
157
+ }
158
+ if (local.length >= company.maxContacts)
159
+ break;
160
+ }
161
+ }
162
+ selected.push(...local);
163
+ coverage.push({ companyName: company.name, companyId: company.companyId ?? null, selected: local.length, reviewRequired: review.filter(p => p.companyId === company.companyId).length, ceiling: company.maxContacts,
164
+ status: !company.companyId ? "unresolved_company" : jobs.some(j => !results[j.key]) ? "pending" : local.length ? "review_ready" : "no_verified_matches",
165
+ 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, stoppedReason: results[j.key]?.stoppedReason ?? null, searchComplete: results[j.key]?.totalResults != null ? !results[j.key].stoppedReason && results[j.key].people.length >= results[j.key].totalResults : null })) });
166
+ }
167
+ const jobs = planCompanySearches(brief);
168
+ const completed = jobs.filter(j => results[j.key]);
169
+ const incomplete = completed.filter(j => results[j.key].stoppedReason || results[j.key].totalResults != null && results[j.key].people.length < results[j.key].totalResults);
170
+ const unknown = completed.filter(j => results[j.key].totalResults == null);
171
+ const selectedIds = new Set(selected.map(p => String(p.profileUrl)));
172
+ const reviewIds = new Set(review.map(p => String(p.profileUrl)));
173
+ const unresolved = brief.companies.filter(c => !c.companyId);
174
+ const progress = {
175
+ selectedPeople: selectedIds.size, reviewRows: review.length, reviewPeople: reviewIds.size,
176
+ reviewOnlyPeople: [...reviewIds].filter(id => !selectedIds.has(id)).length,
177
+ candidateRows: completed.reduce((n, j) => n + results[j.key].people.length, 0),
178
+ completedSearches: completed.length, totalSearches: jobs.length, remainingSearches: jobs.length - completed.length,
179
+ partialSearches: incomplete.length, unknownCoverageSearches: unknown.length, unresolvedCompanies: unresolved.length,
180
+ collectionComplete: completed.length === jobs.length && !unresolved.length && !incomplete.length && !unknown.length,
181
+ geography: brief.regionIds.length ? "explicit_regions" : "unrestricted",
182
+ };
183
+ const nextActions = [
184
+ ...(progress.remainingSearches ? ["Resume the unchanged brief and output directory to collect pending searches."] : []),
185
+ ...(unresolved.length ? ["Use --review in a terminal to choose a verified exact-name identity. Inspect recovery.json for non-exact decisions or an intentional --retry-unresolved --resolve-companies retry."] : []),
186
+ ...(incomplete.length || unknown.length ? ["Use --continue-partial quota or exhaustive to resume eligible pages; inspect stoppedReason before claiming exhaustive coverage."] : []),
187
+ ...(review.length ? ["Use --verify-employers for canonical names, or evidence-backed --review-file aliases. Ambiguous roles remain review-only."] : []),
188
+ ];
189
+ const outcome = unresolved.length ? "needs_review" : !progress.collectionComplete ? "incomplete" : review.length ? "needs_review" : "complete";
190
+ return { outcome, selected, review, rejected, coverage, progress, nextActions, rolePolicyVersion: 2, outreachStarted: false, emailEnrichmentStarted: false };
191
+ }
192
+ export function companyLeadCsv(rows) {
193
+ const columns = ["companyName", "companyId", "fullName", "title", "department", "profileUrl", "location", "sourceQueryUrl", "observedAt", "sourceCompanyName", "reason", "reasons", "employerAliasEvidence"];
194
+ const escape = (value) => { let s = String(value ?? ""); if (/^[=+@\-\t\r]/.test(s))
195
+ s = "'" + s; return `"${s.replace(/"/g, '""')}"`; };
196
+ return [columns.join(","), ...rows.map(r => columns.map(c => escape(r[c])).join(","))].join("\n") + "\n";
197
+ }
198
+ export async function runCompanyResearch(input) {
199
+ await mkdir(input.outDir, { recursive: true, mode: 0o700 });
200
+ const lock = path.join(input.outDir, ".research-lock");
201
+ try {
202
+ await mkdir(lock, { mode: 0o700 });
203
+ }
204
+ catch (e) {
205
+ if (e.code === "EEXIST")
206
+ throw new Error("Research directory is locked by another run. If it crashed, remove only .research-lock after confirming the process stopped.");
207
+ throw e;
208
+ }
209
+ try {
210
+ return await runCompanyResearchLocked(input);
211
+ }
212
+ finally {
213
+ await rmdir(lock);
214
+ }
215
+ }
216
+ async function runCompanyResearchLocked(input) {
217
+ const { brief, outDir, scope } = input;
218
+ await mkdir(outDir, { recursive: true, mode: 0o700 });
219
+ const fingerprint = createHash("sha256").update(JSON.stringify({ brief, scope })).digest("hex");
220
+ const checkpoint = path.join(outDir, "checkpoint.json");
221
+ let state = { fingerprint, results: {} };
222
+ try {
223
+ state = JSON.parse(await readFile(checkpoint, "utf8"));
224
+ if (state.fingerprint !== fingerprint)
225
+ throw new Error("Brief or workspace changed. Use a new output directory.");
226
+ }
227
+ catch (e) {
228
+ if (e.code !== "ENOENT")
229
+ throw e;
230
+ }
231
+ const save = async (file, value) => { await writeFile(file + ".tmp", value, { mode: 0o600 }); await chmod(file + ".tmp", 0o600); await rename(file + ".tmp", file); };
232
+ let review = input.review ? CompanyReviewSchema.parse(input.review) : undefined;
233
+ if (!review) {
234
+ try {
235
+ review = CompanyReviewSchema.parse(JSON.parse(await readFile(path.join(outDir, "review-decisions.json"), "utf8")));
236
+ }
237
+ catch (e) {
238
+ if (e.code !== "ENOENT")
239
+ throw e;
240
+ }
241
+ }
242
+ if (review && review.fingerprint !== fingerprint)
243
+ throw new Error("Review decisions belong to a different brief or workspace.");
244
+ for (const identity of review?.identities ?? []) {
245
+ const index = brief.companies.findIndex(c => c.name === identity.targetName);
246
+ if (index < 0 || brief.companies[index].companyId || state.resolutions?.[String(index)] && state.resolutions[String(index)].companyId !== identity.companyId)
247
+ throw new Error("Reviewed identity must target an unresolved company in this brief.");
248
+ const occupied = [...brief.companies.flatMap(c => c.companyId ? [c.companyId] : []), ...Object.entries(state.resolutions ?? {}).filter(([i]) => i !== String(index)).flatMap(([, r]) => r ? [r.companyId] : [])];
249
+ if (occupied.includes(identity.companyId))
250
+ throw new Error("Reviewed identity is already assigned to another target.");
251
+ state.resolutions ??= {};
252
+ state.resolutions[String(index)] = { companyId: identity.companyId, companyName: identity.targetName, canonicalName: identity.canonicalName, evidenceUrl: identity.evidenceUrl };
253
+ }
254
+ const effectiveBrief = () => CompanyBriefSchema.parse({ ...brief, companies: brief.companies.map((company, index) => {
255
+ const resolution = state.resolutions?.[String(index)];
256
+ const resolved = !company.companyId && resolution ? { ...company, companyId: resolution.companyId } : company;
257
+ const evidence = resolved.companyId ? state.employerEvidence?.[resolved.companyId] : undefined;
258
+ const aliases = [...(resolved.verifiedEmployerAliases ?? []), ...(review?.employerAliases.filter(a => a.companyId === resolved.companyId).map(({ name, evidenceUrl }) => ({ name, evidenceUrl })) ?? [])];
259
+ if (resolution?.canonicalName)
260
+ aliases.push({ name: resolution.canonicalName, evidenceUrl: resolution.evidenceUrl });
261
+ if (evidence && companyIdentityKey(evidence.name) === companyIdentityKey(resolved.name))
262
+ aliases.push({ name: evidence.name, evidenceUrl: evidence.evidenceUrl });
263
+ return { ...resolved, verifiedEmployerAliases: aliases };
264
+ }) });
265
+ const allowed = (name) => !input.onlyCompanies?.length || input.onlyCompanies.includes(name);
266
+ for (const name of input.onlyCompanies ?? [])
267
+ if (!brief.companies.some(c => c.name === name))
268
+ throw new Error(`Unknown target company: ${name}`);
269
+ for (const alias of review?.employerAliases ?? [])
270
+ if (!effectiveBrief().companies.some(c => c.companyId === alias.companyId))
271
+ throw new Error("Reviewed alias targets an unknown company ID.");
272
+ if (input.review) {
273
+ await save(path.join(outDir, "review-decisions.json"), JSON.stringify(review, null, 2));
274
+ await save(checkpoint, JSON.stringify(state));
275
+ }
276
+ const publish = async () => {
277
+ const report = shortlistCompanyLeads(effectiveBrief(), state.results);
278
+ const aliases = new Map();
279
+ for (const row of report.review.filter(r => r.reason === "employer_name_differs")) {
280
+ const id = String(row.companyId), name = String(row.sourceCompanyName);
281
+ aliases.set(`${id}:${name}`, { companyId: id, targetName: String(row.companyName), name, evidenceUrl: `https://www.linkedin.com/sales/company/${id}`, status: "needs_verification" });
282
+ }
283
+ await save(path.join(outDir, "contacts.csv"), companyLeadCsv(report.selected));
284
+ await save(path.join(outDir, "review.csv"), companyLeadCsv(report.review));
285
+ await save(path.join(outDir, "coverage.json"), JSON.stringify(report, null, 2));
286
+ await save(path.join(outDir, "company-resolutions.json"), JSON.stringify(state.resolutions ?? {}, null, 2));
287
+ await save(path.join(outDir, "recovery.json"), JSON.stringify({ fingerprint, aliases: [...aliases.values()], identities: brief.companies.flatMap((c, i) => !c.companyId && !state.resolutions?.[String(i)] ? [{ targetName: c.name, diagnostic: state.diagnostics?.[String(i)] ?? { reason: "legacy_unknown" } }] : []), reviewFileTemplate: { fingerprint, employerAliases: [], identities: [] } }, null, 2));
288
+ return report;
289
+ };
290
+ let performed = 0;
291
+ try {
292
+ if (input.verifyEmployer)
293
+ for (const company of effectiveBrief().companies) {
294
+ if (!company.companyId || !allowed(company.name) || state.employerEvidence?.[company.companyId])
295
+ continue;
296
+ if (!Object.values(state.results).some(result => result.people.some(p => String(p.companyId) === company.companyId)))
297
+ continue;
298
+ const evidence = await input.verifyEmployer(company.companyId);
299
+ if (evidence?.companyId === company.companyId) {
300
+ state.employerEvidence ??= {};
301
+ state.employerEvidence[company.companyId] = evidence;
302
+ await save(checkpoint, JSON.stringify(state));
303
+ await publish();
304
+ }
305
+ }
306
+ // Known IDs run first; an unresolved identity cannot block already prepared work.
307
+ const collect = async () => {
308
+ for (const job of planCompanySearches(effectiveBrief())) {
309
+ if (!allowed(job.company.name))
310
+ continue;
311
+ const previous = state.results[job.key];
312
+ const defaultBudgetFinished = previous && (previous.nextOffset == null || previous.nextOffset >= brief.candidatesPerDepartment);
313
+ if (previous && (!input.continuePartial && defaultBudgetFinished || previous.totalResults != null && previous.people.length >= previous.totalResults || previous.stoppedReason))
314
+ continue;
315
+ if (previous && input.continuePartial === "quota" && shortlistCompanyLeads(effectiveBrief(), state.results).coverage.find(c => c.companyId === job.company.companyId).selected >= job.company.maxContacts)
316
+ continue;
317
+ if (performed >= input.maxSearches)
318
+ break;
319
+ performed++;
320
+ if (input.searchPage) {
321
+ const result = previous ? { ...previous, people: [...previous.people] } : { people: [], totalResults: null, fetchedPages: 0, nextOffset: 0 };
322
+ // Old checkpoints lack a reliable raw offset; replay the first page once and deduplicate.
323
+ let start = result.nextOffset ?? 0;
324
+ const seen = new Set(result.people.map(p => canonicalProfile(p.profileUrl)));
325
+ const target = input.continuePartial ? 2500 : brief.candidatesPerDepartment;
326
+ while (start < target) {
327
+ const replayingLegacyFirstPage = previous != null && previous.nextOffset == null && start === 0;
328
+ const count = Math.min(100, target - start);
329
+ const page = await input.searchPage(job, start, count);
330
+ result.fetchedPages++;
331
+ if (result.totalResults != null && page.totalResults !== result.totalResults)
332
+ result.stoppedReason = "reported_total_changed";
333
+ result.totalResults ??= page.totalResults;
334
+ let added = 0;
335
+ for (const person of page.people) {
336
+ const key = canonicalProfile(person.profileUrl);
337
+ if (key && !seen.has(key)) {
338
+ seen.add(key);
339
+ result.people.push(person);
340
+ added++;
341
+ }
342
+ }
343
+ start += page.rawCount;
344
+ result.nextOffset = start;
345
+ if ((page.rawCount === 0 || !added && !replayingLegacyFirstPage) && (result.totalResults == null || result.people.length < result.totalResults))
346
+ result.stoppedReason = "empty_or_duplicate_page";
347
+ state.results[job.key] = result;
348
+ await save(checkpoint, JSON.stringify(state));
349
+ await publish();
350
+ if (result.stoppedReason || result.totalResults != null && start >= result.totalResults)
351
+ break;
352
+ if (input.continuePartial === "quota" && shortlistCompanyLeads(effectiveBrief(), state.results).coverage.find(c => c.companyId === job.company.companyId).selected >= job.company.maxContacts)
353
+ break;
354
+ }
355
+ }
356
+ else
357
+ state.results[job.key] = await input.search(job);
358
+ await save(checkpoint, JSON.stringify(state));
359
+ await publish();
360
+ }
361
+ };
362
+ await collect();
363
+ if (input.resolveCompany || input.diagnoseCompany) {
364
+ let resolutionsPerformed = 0;
365
+ for (const [index, company] of brief.companies.entries()) {
366
+ if (performed >= input.maxSearches || resolutionsPerformed >= input.maxSearches)
367
+ break;
368
+ const savedResolution = state.resolutions?.[String(index)];
369
+ if (!allowed(company.name) || company.companyId || (Object.hasOwn(state.resolutions ?? {}, String(index)) && !(input.retryUnresolved && savedResolution === null)))
370
+ continue;
371
+ const diagnosed = input.diagnoseCompany ? await input.diagnoseCompany(company) : undefined;
372
+ const resolution = diagnosed ? diagnosed.resolution : await input.resolveCompany(company);
373
+ if (diagnosed) {
374
+ state.diagnostics ??= {};
375
+ state.diagnostics[String(index)] = diagnosed.diagnostic;
376
+ }
377
+ resolutionsPerformed++;
378
+ if (resolution && (!/^[1-9]\d*$/.test(resolution.companyId) || words(resolution.companyName) !== words(company.name)))
379
+ throw new Error("Company resolver returned a non-exact identity.");
380
+ const occupied = new Set(effectiveBrief().companies.map(c => c.companyId).filter(Boolean));
381
+ state.resolutions ??= {};
382
+ state.resolutions[String(index)] = resolution && !occupied.has(resolution.companyId) ? resolution : null;
383
+ if (resolution && occupied.has(resolution.companyId) && state.diagnostics?.[String(index)])
384
+ state.diagnostics[String(index)].reason = "shared_identity";
385
+ await save(checkpoint, JSON.stringify(state));
386
+ await publish();
387
+ await collect();
388
+ }
389
+ }
390
+ if (input.reviewIdentity)
391
+ for (const [index, company] of brief.companies.entries()) {
392
+ if (!allowed(company.name) || company.companyId || state.resolutions?.[String(index)])
393
+ continue;
394
+ const diagnostic = state.diagnostics?.[String(index)];
395
+ if (!diagnostic)
396
+ continue;
397
+ const choice = await input.reviewIdentity(company, diagnostic);
398
+ if (!choice)
399
+ continue;
400
+ if (!diagnostic.candidates.some(c => c.companyId === choice.companyId && companyIdentityKey(c.name) === companyIdentityKey(choice.name)))
401
+ throw new Error("Reviewed identity must match a verified candidate from this research.");
402
+ if (effectiveBrief().companies.some(c => c.companyId === choice.companyId))
403
+ throw new Error("Reviewed identity is already assigned to another target.");
404
+ const identity = { targetName: company.name, companyId: choice.companyId, canonicalName: choice.name, evidenceUrl: choice.evidenceUrl };
405
+ review = CompanyReviewSchema.parse({ fingerprint, employerAliases: review?.employerAliases ?? [], identities: [...(review?.identities ?? []), identity] });
406
+ // The durable review overlay is written first. A crash before the checkpoint
407
+ // write is recovered by the normal review-file replay on the next invocation.
408
+ await save(path.join(outDir, "review-decisions.json"), JSON.stringify(review, null, 2));
409
+ state.resolutions ??= {};
410
+ state.resolutions[String(index)] = { companyId: choice.companyId, companyName: company.name, canonicalName: choice.name, evidenceUrl: choice.evidenceUrl };
411
+ await save(checkpoint, JSON.stringify(state));
412
+ await publish();
413
+ await collect();
414
+ }
415
+ }
416
+ catch (e) {
417
+ await publish();
418
+ throw e;
419
+ }
420
+ const report = await publish();
421
+ return { status: "ok", ...report, completedSearches: Object.keys(state.results).length, totalSearches: planCompanySearches(effectiveBrief()).length, output: outDir, resumable: true };
422
+ }
@@ -0,0 +1,84 @@
1
+ import { z } from "zod";
2
+ // Only presentation annotations and legal suffixes are interchangeable. Group,
3
+ // division, brand and subsidiary names remain different identities.
4
+ export function companyIdentityKey(name) {
5
+ let key = name.replace(/\s*\((?:CH|DE|AT|UK|US|USA)\)\s*$/i, "")
6
+ .normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/ß/g, "ss").toLowerCase()
7
+ .replace(/&/g, " and ").replace(/[^a-z0-9]+/g, " ")
8
+ .replace(/\s+/g, " ").trim();
9
+ // Do not erase a brand token in names such as AG Insurance or SE Ranking.
10
+ while (/\s(?:gmbh|kgaa|ag|se|ltd|limited|inc|incorporated|llc)$/.test(key))
11
+ key = key.replace(/\s\S+$/, "");
12
+ return key;
13
+ }
14
+ export function companyQueryVariants(name) {
15
+ const plain = name.replace(/\s*\([^)]*\)\s*/g, " ").trim();
16
+ const primary = plain.split(/\s+\/\s+/)[0].trim();
17
+ return [...new Set([name, plain, primary, companyIdentityKey(primary)].filter(Boolean))].slice(0, 3);
18
+ }
19
+ export async function resolveCompanyIdentity(name, search, details) {
20
+ const diagnostic = { reason: "no_exact_match", attempts: [], candidates: [], observedAt: new Date().toISOString() };
21
+ const candidates = new Map();
22
+ const proven = new Map();
23
+ let incomplete = false, oversized = false;
24
+ for (const query of companyQueryVariants(name)) {
25
+ const first = await search(query, 0);
26
+ let count = first.rawCount;
27
+ const total = first.total;
28
+ const queryCandidates = new Map(first.candidates.map(c => [c.companyId, c]));
29
+ let complete = total != null && total <= 1000;
30
+ for (const c of first.candidates)
31
+ candidates.set(c.companyId, c);
32
+ if (complete)
33
+ for (let start = 25; start < total; start += 25) {
34
+ const page = await search(query, start);
35
+ if (page.total !== total || page.rawCount === 0) {
36
+ complete = false;
37
+ break;
38
+ }
39
+ count += page.rawCount;
40
+ for (const c of page.candidates) {
41
+ candidates.set(c.companyId, c);
42
+ queryCandidates.set(c.companyId, c);
43
+ }
44
+ }
45
+ complete = complete && count >= total && queryCandidates.size >= total;
46
+ diagnostic.attempts.push({ query, total, collected: count, complete });
47
+ incomplete ||= !complete;
48
+ oversized ||= total != null && total > 1000;
49
+ // A complete exact query is sufficient; avoid repeating equivalent searches.
50
+ if (complete)
51
+ for (const c of queryCandidates.values())
52
+ proven.set(c.companyId, c);
53
+ if (complete && [...queryCandidates.values()].some(c => companyIdentityKey(c.name) === companyIdentityKey(name))) {
54
+ incomplete = false;
55
+ oversized = false;
56
+ break;
57
+ }
58
+ }
59
+ diagnostic.candidates = [...candidates.values()].slice(0, 100);
60
+ const exact = [...proven.values()].filter(c => companyIdentityKey(c.name) === companyIdentityKey(name));
61
+ if (/\/|\([^)]*(?:\/|,)[^)]*\)/.test(name))
62
+ diagnostic.reason = "entity_decision_required";
63
+ else if (oversized)
64
+ diagnostic.reason = "oversized";
65
+ else if (incomplete)
66
+ diagnostic.reason = "incomplete";
67
+ else if (exact.length > 1)
68
+ diagnostic.reason = "ambiguous";
69
+ else if (exact.length === 1) {
70
+ const evidence = await details(exact[0].companyId);
71
+ if (evidence?.companyId === exact[0].companyId && companyIdentityKey(evidence.name) === companyIdentityKey(name)) {
72
+ diagnostic.reason = "resolved";
73
+ return { resolution: { companyId: evidence.companyId, companyName: name, canonicalName: evidence.name, evidenceUrl: evidence.evidenceUrl }, diagnostic };
74
+ }
75
+ diagnostic.reason = "detail_mismatch";
76
+ }
77
+ return { resolution: null, diagnostic };
78
+ }
79
+ const evidenceUrl = z.url().refine(value => /^https:\/\//i.test(value), "Evidence must use HTTPS");
80
+ export const CompanyReviewSchema = z.object({
81
+ fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
82
+ employerAliases: z.array(z.object({ companyId: z.string().regex(/^[1-9]\d*$/), name: z.string().trim().min(1), evidenceUrl }).strict()).default([]),
83
+ identities: z.array(z.object({ targetName: z.string().trim().min(1), companyId: z.string().regex(/^[1-9]\d*$/), canonicalName: z.string().trim().min(1), evidenceUrl }).strict()).default([]),
84
+ }).strict();