salesprompter-cli 0.1.50 → 0.1.52

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
@@ -70,6 +70,12 @@ salesprompter leads:collect \
70
70
  --linkedin-url "$SALES_NAV_PEOPLE_URL" \
71
71
  --max-results 1000
72
72
 
73
+ # Enrich one or more stored imports: Sales Navigator company -> official domain -> Hunter email
74
+ # This updates workspace data only. It does not create or start outreach.
75
+ # Stale imported company ids fall back to exact-name Account Search automatically.
76
+ salesprompter leads:enrich-import \
77
+ --run-id "$CLI_IMPORT_RUN_ID"
78
+
73
79
  salesprompter affiliate:launch \
74
80
  --affiliate-link "https://example.com/?ref=you" \
75
81
  --linkedin-url "https://www.linkedin.com/sales/search/people?query=..." \
package/dist/cli.js CHANGED
@@ -258,6 +258,51 @@ const CliEmailEnrichmentCompaniesResponseSchema = z.object({
258
258
  linkedinCompanyPage: z.string().nullable()
259
259
  }))
260
260
  });
261
+ const CliImportCompanyCandidateSchema = z.object({
262
+ companyKey: z.string().min(1),
263
+ companyId: z.string().nullable(),
264
+ companyName: z.string().nullable(),
265
+ salesNavCompanyUrl: z.string().nullable(),
266
+ linkedinCompanyUrl: z.string().nullable(),
267
+ personCount: z.number().int().nonnegative(),
268
+ profileStatus: z.string().nullable(),
269
+ profileDomain: z.string().nullable()
270
+ });
271
+ const CliImportCompanyCandidatesResponseSchema = z.object({
272
+ companies: z.array(CliImportCompanyCandidateSchema)
273
+ });
274
+ const CliImportCompanySaveResponseSchema = z.object({
275
+ saved: z.number().int().nonnegative(),
276
+ enriched: z.number().int().nonnegative(),
277
+ domains: z.number().int().nonnegative(),
278
+ notFound: z.number().int().nonnegative(),
279
+ failed: z.number().int().nonnegative()
280
+ });
281
+ const CliImportHunterBatchResponseSchema = z.object({
282
+ selected: z.number().int().nonnegative(),
283
+ processed: z.number().int().nonnegative(),
284
+ found: z.number().int().nonnegative(),
285
+ notFound: z.number().int().nonnegative(),
286
+ failed: z.number().int().nonnegative(),
287
+ halted: z.boolean(),
288
+ error: z.string().nullable()
289
+ });
290
+ const CliImportEnrichmentStatusSchema = z.object({
291
+ runCount: z.coerce.number().int().nonnegative(),
292
+ memberships: z.coerce.number().int().nonnegative(),
293
+ uniquePeople: z.coerce.number().int().nonnegative(),
294
+ distinctCompanies: z.coerce.number().int().nonnegative(),
295
+ companiesEnriched: z.coerce.number().int().nonnegative(),
296
+ companiesNotFound: z.coerce.number().int().nonnegative(),
297
+ companiesFailed: z.coerce.number().int().nonnegative(),
298
+ domainsFound: z.coerce.number().int().nonnegative(),
299
+ peopleWithDomain: z.coerce.number().int().nonnegative(),
300
+ emailsFound: z.coerce.number().int().nonnegative(),
301
+ emailsNotFound: z.coerce.number().int().nonnegative(),
302
+ emailsFailed: z.coerce.number().int().nonnegative(),
303
+ emailsProcessing: z.coerce.number().int().nonnegative(),
304
+ emailsPending: z.coerce.number().int().nonnegative()
305
+ });
261
306
  const SalesNavigatorLaunchDiagnosticsSchema = z.object({
262
307
  orderedCandidateAgentIds: z.array(z.string().min(1)),
263
308
  runningAgentIds: z.array(z.string().min(1)),
@@ -479,6 +524,7 @@ const cliPacks = [
479
524
  commands: [
480
525
  "leads:discover",
481
526
  "leads:collect",
527
+ "leads:enrich-import",
482
528
  "search:run",
483
529
  "search:status",
484
530
  "search:export",
@@ -517,6 +563,7 @@ const helpAliasByCommandName = new Map([
517
563
  ["linkedin-products:scrape", "market:scrape"],
518
564
  ["salesnav:from-product-category", "leads:discover"],
519
565
  ["salesnav:people:collect", "leads:collect"],
566
+ ["salesnav:people:enrich", "leads:enrich-import"],
520
567
  ["salesnav:crawl", "search:run"],
521
568
  ["salesnav:crawl:status", "search:status"],
522
569
  ["salesnav:export", "search:export"],
@@ -562,6 +609,7 @@ const helpVisibleCommandNames = new Set([
562
609
  "linkedin-products:scrape",
563
610
  "salesnav:from-product-category",
564
611
  "salesnav:people:collect",
612
+ "salesnav:people:enrich",
565
613
  "salesnav:crawl",
566
614
  "salesnav:crawl:status",
567
615
  "salesnav:export",
@@ -6696,6 +6744,29 @@ async function enrichDirectEmailCompaniesViaApp(session, payload) {
6696
6744
  }), CliEmailEnrichmentCompaniesResponseSchema);
6697
6745
  return value;
6698
6746
  }
6747
+ async function runCliImportEnrichmentAction(session, body, schema) {
6748
+ const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/leads/imports/enrichment`, {
6749
+ method: "POST",
6750
+ headers: {
6751
+ "Content-Type": "application/json",
6752
+ Authorization: `Bearer ${currentSession.accessToken}`
6753
+ },
6754
+ body: JSON.stringify(body)
6755
+ }), schema);
6756
+ return value;
6757
+ }
6758
+ async function getCliImportCompanyCandidatesViaApp(session, input) {
6759
+ return runCliImportEnrichmentAction(session, { action: "company-candidates", ...input }, CliImportCompanyCandidatesResponseSchema);
6760
+ }
6761
+ async function saveCliImportCompanyProfilesViaApp(session, profiles) {
6762
+ return runCliImportEnrichmentAction(session, { action: "save-companies", profiles }, CliImportCompanySaveResponseSchema);
6763
+ }
6764
+ async function runCliImportHunterBatchViaApp(session, input) {
6765
+ return runCliImportEnrichmentAction(session, { action: "hunter", ...input }, CliImportHunterBatchResponseSchema);
6766
+ }
6767
+ async function getCliImportEnrichmentStatusViaApp(session, runIds) {
6768
+ return runCliImportEnrichmentAction(session, { action: "status", runIds }, CliImportEnrichmentStatusSchema);
6769
+ }
6699
6770
  async function fetchLinkedInCompaniesBackfillStatus(session, payload) {
6700
6771
  const url = new URL('/api/cli/linkedin-companies/status', session.apiBaseUrl);
6701
6772
  url.searchParams.set('clientId', String(payload.clientId));
@@ -6726,6 +6797,16 @@ class LocalSalesNavigatorRateLimitError extends Error {
6726
6797
  this.retryAfterMs = retryAfterMs;
6727
6798
  }
6728
6799
  }
6800
+ class LocalSalesNavigatorHttpError extends Error {
6801
+ status;
6802
+ body;
6803
+ constructor(status, body, message) {
6804
+ super(message);
6805
+ this.name = "LocalSalesNavigatorHttpError";
6806
+ this.status = status;
6807
+ this.body = body;
6808
+ }
6809
+ }
6729
6810
  function stripShellQuotes(value) {
6730
6811
  const trimmed = value.trim();
6731
6812
  if (trimmed.length >= 2) {
@@ -7798,7 +7879,7 @@ async function createLocalAccountSearchBrowserRelay(requestedPort) {
7798
7879
  const recovery = status === 401 || status === 403
7799
7880
  ? " Refresh the Sales Navigator search, capture the current csrf-token from a native Lead Search request, and replay this task with the supplied headers."
7800
7881
  : "";
7801
- current.reject(new Error(`Sales Navigator browser relay failed with HTTP ${status}: ${bodyPreview}.${recovery}`));
7882
+ current.reject(new LocalSalesNavigatorHttpError(status, body, `Sales Navigator browser relay failed with HTTP ${status}: ${bodyPreview}.${recovery}`));
7802
7883
  writeLocalAccountSearchRelayJson(response, 200, {
7803
7884
  status: "failed",
7804
7885
  });
@@ -7840,8 +7921,9 @@ async function createLocalAccountSearchBrowserRelay(requestedPort) {
7840
7921
  const parsedUrl = new URL(request.url);
7841
7922
  if (!/(^|\.)linkedin\.com$/i.test(parsedUrl.hostname) ||
7842
7923
  !(parsedUrl.pathname.includes("/sales-api/salesApiAccountSearch") ||
7843
- parsedUrl.pathname.includes("/sales-api/salesApiLeadSearch"))) {
7844
- throw new Error("Browser relay only accepts LinkedIn Account or Lead Search API URLs.");
7924
+ parsedUrl.pathname.includes("/sales-api/salesApiLeadSearch") ||
7925
+ parsedUrl.pathname.includes("/sales-api/salesApiCompanies"))) {
7926
+ throw new Error("Browser relay only accepts LinkedIn Account Search, Lead Search, or company-detail API URLs.");
7845
7927
  }
7846
7928
  const headers = Object.fromEntries(Object.entries(request.headers)
7847
7929
  .map(([name, value]) => [name.toLowerCase(), value])
@@ -7861,9 +7943,7 @@ async function createLocalAccountSearchBrowserRelay(requestedPort) {
7861
7943
  requestId: `sales-nav-search-${requestSequence}`,
7862
7944
  url: request.url,
7863
7945
  headers,
7864
- requiredHeaders: parsedUrl.pathname.includes("salesApiLeadSearch")
7865
- ? ["csrf-token"]
7866
- : [],
7946
+ requiredHeaders: ["csrf-token"],
7867
7947
  resolve,
7868
7948
  reject,
7869
7949
  };
@@ -13343,6 +13423,707 @@ async function runSalesNavigatorPeopleCollectCommand(options) {
13343
13423
  await writeJsonFile(options.out, payload);
13344
13424
  return payload;
13345
13425
  }
13426
+ class CliImportCompanyRateLimitError extends Error {
13427
+ constructor(message) {
13428
+ super(message);
13429
+ this.name = "CliImportCompanyRateLimitError";
13430
+ }
13431
+ }
13432
+ function collectRunId(value, previous) {
13433
+ return [...previous, ...value.split(",")]
13434
+ .map((entry) => entry.trim())
13435
+ .filter(Boolean);
13436
+ }
13437
+ function normalizeCompanyWebsite(value) {
13438
+ const trimmed = value?.trim() ?? "";
13439
+ if (!trimmed)
13440
+ return null;
13441
+ try {
13442
+ return new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`).toString();
13443
+ }
13444
+ catch {
13445
+ return null;
13446
+ }
13447
+ }
13448
+ const CLI_IMPORT_COMPANY_DECORATION = "(entityUrn,name,employeeCount,employeeDisplayCount,employeeCountRange,account(saved,noteCount,listCount,crmStatus,starred),pictureInfo,companyPictureDisplayImage,companyBackgroundCoverImage,description,industry,location,headquarters,website,revenueRange,crmOpportunities,flagshipCompanyUrl,employeeGrowthPercentages,employees*~fs_salesProfile(entityUrn,firstName,lastName,fullName,pictureInfo,profilePictureDisplayImage),specialties,type,yearFounded)";
13449
+ class CliImportCompanySessionError extends Error {
13450
+ constructor(message) {
13451
+ super(message);
13452
+ this.name = "CliImportCompanySessionError";
13453
+ }
13454
+ }
13455
+ function buildCliImportAccountSearchUrl(companyName) {
13456
+ const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
13457
+ "https://www.linkedin.com";
13458
+ return (`${baseUrl.replace(/\/+$/, "")}/sales-api/salesApiAccountSearch` +
13459
+ `?q=searchQuery&query=(spellCorrectionEnabled:true,keywords:${encodeLinkedInRestliValue(companyName)})` +
13460
+ "&start=0&count=25" +
13461
+ "&decorationId=com.linkedin.sales.deco.desktop.searchv2.AccountSearchResult-4");
13462
+ }
13463
+ function encodeLinkedInRestliValue(value) {
13464
+ return encodeURIComponent(value)
13465
+ .replace(/\(/g, "%28")
13466
+ .replace(/\)/g, "%29");
13467
+ }
13468
+ function buildCliImportCompanyDetailsUrl(companyIds) {
13469
+ const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
13470
+ "https://www.linkedin.com";
13471
+ return (`${baseUrl.replace(/\/+$/, "")}/sales-api/salesApiCompanies` +
13472
+ `?ids=List(${companyIds.map((companyId) => encodeURIComponent(companyId)).join(",")})` +
13473
+ `&decoration=${encodeLinkedInRestliValue(CLI_IMPORT_COMPANY_DECORATION)}`);
13474
+ }
13475
+ function buildCliImportCompanyHeaders(config, companyId) {
13476
+ return {
13477
+ ...buildLinkedInSalesNavigatorAccountSearchHeaders(config),
13478
+ referer: companyId
13479
+ ? `https://www.linkedin.com/sales/company/${companyId}`
13480
+ : "https://www.linkedin.com/sales/search/company",
13481
+ };
13482
+ }
13483
+ async function fetchCliImportSalesNavigatorJson(params) {
13484
+ if (params.browserRelay) {
13485
+ try {
13486
+ const result = await params.browserRelay.request({
13487
+ url: params.url,
13488
+ headers: params.config
13489
+ ? buildCliImportCompanyHeaders(params.config, params.companyId)
13490
+ : {},
13491
+ });
13492
+ const body = cliImportCompanyRecord(result.body);
13493
+ return { status: 200, body, finalUrl: params.url };
13494
+ }
13495
+ catch (error) {
13496
+ if (error instanceof LocalSalesNavigatorRateLimitError) {
13497
+ throw new CliImportCompanyRateLimitError(error.message);
13498
+ }
13499
+ if (error instanceof LocalSalesNavigatorHttpError) {
13500
+ if (error.status === 401 || error.status === 403) {
13501
+ throw new CliImportCompanySessionError(error.message);
13502
+ }
13503
+ return {
13504
+ status: error.status,
13505
+ body: cliImportCompanyRecord(error.body),
13506
+ finalUrl: params.url,
13507
+ };
13508
+ }
13509
+ throw error;
13510
+ }
13511
+ }
13512
+ if (!params.config) {
13513
+ throw new Error("Company enrichment requires a LinkedIn session or --browser-relay-port.");
13514
+ }
13515
+ const controller = new AbortController();
13516
+ const timeout = setTimeout(() => controller.abort(), params.timeoutMs);
13517
+ try {
13518
+ const response = await fetch(params.url, {
13519
+ method: "GET",
13520
+ signal: controller.signal,
13521
+ headers: buildCliImportCompanyHeaders(params.config, params.companyId),
13522
+ });
13523
+ const finalUrl = response.url || params.url;
13524
+ if (response.status === 429 || response.status === 999) {
13525
+ throw new CliImportCompanyRateLimitError(`LinkedIn rate limited ${params.label} (${response.status}). Resume the same command after cooldown.`);
13526
+ }
13527
+ if (response.status === 401 ||
13528
+ response.status === 403 ||
13529
+ /\/uas\/login|\/checkpoint\/challenge|\/authwall/i.test(finalUrl)) {
13530
+ throw new CliImportCompanySessionError(`LinkedIn rejected ${params.label}. Refresh the Sales Navigator session, then rerun the same command.`);
13531
+ }
13532
+ const text = await response.text();
13533
+ if (/sign in to linkedin|authwall|checkpoint\/challenge/i.test(text)) {
13534
+ throw new CliImportCompanySessionError(`LinkedIn redirected ${params.label} to authentication. Refresh the Sales Navigator session, then rerun the same command.`);
13535
+ }
13536
+ let body = null;
13537
+ if (text.trim()) {
13538
+ try {
13539
+ const parsed = JSON.parse(text);
13540
+ body =
13541
+ typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
13542
+ ? parsed
13543
+ : null;
13544
+ }
13545
+ catch {
13546
+ if (response.ok) {
13547
+ throw new Error(`${params.label} returned a non-JSON response (${response.status}).`);
13548
+ }
13549
+ }
13550
+ }
13551
+ return { status: response.status, body, finalUrl };
13552
+ }
13553
+ catch (error) {
13554
+ if (error instanceof CliImportCompanyRateLimitError ||
13555
+ error instanceof CliImportCompanySessionError) {
13556
+ throw error;
13557
+ }
13558
+ if (error instanceof Error && error.name === "AbortError") {
13559
+ throw new Error(`${params.label} timed out after ${params.timeoutMs}ms.`);
13560
+ }
13561
+ throw error;
13562
+ }
13563
+ finally {
13564
+ clearTimeout(timeout);
13565
+ }
13566
+ }
13567
+ function exactCliImportAccountMatch(candidate, accounts) {
13568
+ const sourceName = candidate.companyName ?? "";
13569
+ const expectedNames = new Set([sourceName, aggressivelyCleanLookupCompanyName(sourceName)]
13570
+ .map((value) => normalizeLooseMatchText(value))
13571
+ .filter(Boolean));
13572
+ if (expectedNames.size === 0)
13573
+ return null;
13574
+ return (accounts.find((account) => {
13575
+ const companyName = typeof account.companyName === "string" ? account.companyName : "";
13576
+ const normalizedNames = [
13577
+ companyName,
13578
+ aggressivelyCleanLookupCompanyName(companyName),
13579
+ ].map((value) => normalizeLooseMatchText(value));
13580
+ return normalizedNames.some((value) => value && expectedNames.has(value));
13581
+ }) ?? null);
13582
+ }
13583
+ async function resolveCliImportCompanyIdentity(candidate, config, timeoutMs, browserRelay) {
13584
+ let resolvedCompanyId = candidate.companyId && /^\d+$/.test(candidate.companyId)
13585
+ ? candidate.companyId
13586
+ : null;
13587
+ let salesNavCompanyUrl = candidate.salesNavCompanyUrl;
13588
+ let linkedinCompanyUrl = candidate.linkedinCompanyUrl;
13589
+ let matchedCompanyName = null;
13590
+ let accountSearchUsed = false;
13591
+ let account = null;
13592
+ if (!resolvedCompanyId && candidate.companyName) {
13593
+ accountSearchUsed = true;
13594
+ const queryUrl = buildCliImportAccountSearchUrl(candidate.companyName);
13595
+ const response = await fetchCliImportSalesNavigatorJson({
13596
+ url: queryUrl,
13597
+ config,
13598
+ browserRelay,
13599
+ timeoutMs,
13600
+ label: `Sales Navigator Account Search for ${candidate.companyName}`,
13601
+ });
13602
+ if (response.status < 200 || response.status >= 300) {
13603
+ throw new Error(`Sales Navigator Account Search for ${candidate.companyName} returned ${response.status}.`);
13604
+ }
13605
+ if (response.status >= 200 && response.status < 300 && response.body) {
13606
+ const accounts = extractLocalSalesNavigatorElements(response.body)
13607
+ .map((element) => typeof element === "object" && element !== null && !Array.isArray(element)
13608
+ ? normalizeLocalSalesNavigatorAccount(element, queryUrl)
13609
+ : null)
13610
+ .filter((value) => value !== null);
13611
+ account = exactCliImportAccountMatch(candidate, accounts);
13612
+ }
13613
+ salesNavCompanyUrl =
13614
+ (typeof account?.salesNavCompanyUrl === "string"
13615
+ ? account.salesNavCompanyUrl
13616
+ : null) ?? salesNavCompanyUrl;
13617
+ linkedinCompanyUrl =
13618
+ (typeof account?.linkedinCompanyUrl === "string"
13619
+ ? account.linkedinCompanyUrl
13620
+ : null) ?? linkedinCompanyUrl;
13621
+ matchedCompanyName =
13622
+ typeof account?.companyName === "string" ? account.companyName : null;
13623
+ resolvedCompanyId =
13624
+ (typeof account?.companyId === "string" && /^\d+$/.test(account.companyId)
13625
+ ? account.companyId
13626
+ : null) ??
13627
+ extractLinkedInCompanyIdFromUrl(salesNavCompanyUrl) ??
13628
+ extractLinkedInCompanyIdFromUrl(linkedinCompanyUrl) ??
13629
+ null;
13630
+ }
13631
+ if (!linkedinCompanyUrl && resolvedCompanyId) {
13632
+ linkedinCompanyUrl = normalizeLinkedInCompanyPage(resolvedCompanyId);
13633
+ }
13634
+ if (!salesNavCompanyUrl && resolvedCompanyId) {
13635
+ salesNavCompanyUrl = `https://www.linkedin.com/sales/company/${resolvedCompanyId}`;
13636
+ }
13637
+ return {
13638
+ candidate,
13639
+ resolvedCompanyId,
13640
+ salesNavCompanyUrl,
13641
+ linkedinCompanyUrl,
13642
+ matchedCompanyName,
13643
+ accountSearchUsed,
13644
+ account,
13645
+ };
13646
+ }
13647
+ function cliImportCompanyProfileCommon(resolution) {
13648
+ return {
13649
+ companyKey: resolution.candidate.companyKey,
13650
+ sourceCompanyId: resolution.candidate.companyId,
13651
+ resolvedCompanyId: resolution.resolvedCompanyId,
13652
+ sourceCompanyName: resolution.candidate.companyName,
13653
+ companyName: resolution.matchedCompanyName ?? resolution.candidate.companyName,
13654
+ salesNavCompanyUrl: resolution.salesNavCompanyUrl,
13655
+ linkedinCompanyUrl: resolution.linkedinCompanyUrl,
13656
+ enrichmentSource: resolution.accountSearchUsed
13657
+ ? "sales_api_account_search+sales_api_company_detail"
13658
+ : "sales_api_company_detail",
13659
+ };
13660
+ }
13661
+ function formatCliImportCompanyLocation(value) {
13662
+ if (typeof value === "string")
13663
+ return normalizeLookupWhitespace(value) || null;
13664
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
13665
+ return null;
13666
+ }
13667
+ const record = value;
13668
+ const orderedKeys = [
13669
+ "line1",
13670
+ "line2",
13671
+ "city",
13672
+ "geographicArea",
13673
+ "postalCode",
13674
+ "country",
13675
+ ];
13676
+ return (orderedKeys
13677
+ .map((key) => typeof record[key] === "string"
13678
+ ? normalizeLookupWhitespace(record[key])
13679
+ : "")
13680
+ .filter(Boolean)
13681
+ .join(", ") || null);
13682
+ }
13683
+ function normalizeCliImportCompanyDomain(website) {
13684
+ if (!website)
13685
+ return null;
13686
+ try {
13687
+ const hostname = new URL(website).hostname.replace(/^www\./i, "").toLowerCase();
13688
+ return hostname && !/(^|\.)linkedin\.com$/i.test(hostname) ? hostname : null;
13689
+ }
13690
+ catch {
13691
+ return null;
13692
+ }
13693
+ }
13694
+ function compactCliImportCompanyString(value) {
13695
+ return typeof value === "string" ? normalizeLookupWhitespace(value) || null : null;
13696
+ }
13697
+ function cliImportCompanyRecord(value) {
13698
+ return typeof value === "object" && value !== null && !Array.isArray(value)
13699
+ ? value
13700
+ : null;
13701
+ }
13702
+ function buildCliImportCompanyProfile(resolution, detail, input) {
13703
+ const common = cliImportCompanyProfileCommon(resolution);
13704
+ if (!detail) {
13705
+ return {
13706
+ ...common,
13707
+ status: input.status === 404 || input.status === 410 ? "not_found" : "failed",
13708
+ error: input.error ??
13709
+ `Sales Navigator company detail returned ${input.status || "no result"}`,
13710
+ rawPayload: {
13711
+ accountSearchUsed: resolution.accountSearchUsed,
13712
+ httpStatus: input.status,
13713
+ },
13714
+ };
13715
+ }
13716
+ const accountRaw = cliImportCompanyRecord(resolution.account?.rawLocalSalesNavigatorAccountResult);
13717
+ const accountWebsite = compactCliImportCompanyString(resolution.account?.website);
13718
+ const website = normalizeCompanyWebsite(compactCliImportCompanyString(detail.website) ?? accountWebsite ?? undefined);
13719
+ const specialties = Array.isArray(detail.specialties)
13720
+ ? detail.specialties
13721
+ .filter((value) => typeof value === "string")
13722
+ .map((value) => normalizeLookupWhitespace(value))
13723
+ .filter(Boolean)
13724
+ .join(", ") || null
13725
+ : compactCliImportCompanyString(detail.specialties);
13726
+ const foundedCandidate = detail.yearFounded ?? detail.foundedYear;
13727
+ const founded = typeof foundedCandidate === "number" && Number.isInteger(foundedCandidate)
13728
+ ? foundedCandidate
13729
+ : typeof foundedCandidate === "string" && /^\d{4}$/.test(foundedCandidate)
13730
+ ? Number(foundedCandidate)
13731
+ : null;
13732
+ const revenueRange = cliImportCompanyRecord(detail.revenueRange);
13733
+ const employeeGrowthPercentages = Array.isArray(detail.employeeGrowthPercentages)
13734
+ ? detail.employeeGrowthPercentages.filter((value) => typeof value === "object" && value !== null && !Array.isArray(value))
13735
+ : [];
13736
+ const flagshipCompanyUrl = normalizeCompanyWebsite(compactCliImportCompanyString(detail.flagshipCompanyUrl) ?? undefined);
13737
+ const linkedinCompanyUrl = flagshipCompanyUrl && /(^|\.)linkedin\.com$/i.test(new URL(flagshipCompanyUrl).hostname)
13738
+ ? flagshipCompanyUrl
13739
+ : resolution.linkedinCompanyUrl;
13740
+ const accountEmployeeCount = typeof resolution.account?.employeeCount === "number"
13741
+ ? resolution.account.employeeCount
13742
+ : typeof detail.employeeDisplayCount === "number"
13743
+ ? Math.max(0, Math.trunc(detail.employeeDisplayCount))
13744
+ : typeof detail.employeeCount === "number"
13745
+ ? Math.max(0, Math.trunc(detail.employeeCount))
13746
+ : null;
13747
+ const followerCount = typeof resolution.account?.followerCount === "number"
13748
+ ? resolution.account.followerCount
13749
+ : null;
13750
+ return {
13751
+ ...common,
13752
+ companyName: compactCliImportCompanyString(detail.name) ?? common.companyName,
13753
+ linkedinCompanyUrl,
13754
+ website,
13755
+ domain: normalizeCliImportCompanyDomain(website),
13756
+ industry: compactCliImportCompanyString(detail.industry) ??
13757
+ compactCliImportCompanyString(resolution.account?.industry),
13758
+ companySize: extractFirstNestedStringByKeys(accountRaw, [
13759
+ "employeeCountRange",
13760
+ "companySize",
13761
+ ]) ?? compactCliImportCompanyString(detail.employeeCountRange),
13762
+ companyType: compactCliImportCompanyString(detail.type),
13763
+ location: formatCliImportCompanyLocation(detail.location) ??
13764
+ compactCliImportCompanyString(resolution.account?.location),
13765
+ headquarters: formatCliImportCompanyLocation(detail.headquarters) ??
13766
+ compactCliImportCompanyString(resolution.account?.headquarters),
13767
+ employeesOnLinkedIn: accountEmployeeCount,
13768
+ followerCount,
13769
+ description: compactCliImportCompanyString(detail.description) ??
13770
+ compactCliImportCompanyString(resolution.account?.description),
13771
+ tagline: null,
13772
+ specialties,
13773
+ founded,
13774
+ revenueRange,
13775
+ employeeGrowthPercentages,
13776
+ logoUrl: buildLinkedInImageUrl(detail.companyPictureDisplayImage) ??
13777
+ buildLinkedInImageUrl(detail.pictureInfo),
13778
+ bannerUrl: buildLinkedInImageUrl(detail.companyBackgroundCoverImage),
13779
+ flagshipCompanyUrl,
13780
+ status: "enriched",
13781
+ error: null,
13782
+ rawPayload: {
13783
+ accountSearchUsed: resolution.accountSearchUsed,
13784
+ httpStatus: input.status,
13785
+ detail,
13786
+ accountSearchResult: accountRaw,
13787
+ },
13788
+ };
13789
+ }
13790
+ async function runSalesNavigatorImportEnrichmentCommand(options) {
13791
+ if (options.companiesOnly && options.hunterOnly) {
13792
+ throw new Error("Use either --companies-only or --hunter-only, not both.");
13793
+ }
13794
+ const runIds = Array.from(new Set(options.runId.map((runId) => z.string().uuid().parse(runId))));
13795
+ const companyTimeoutMs = z.coerce.number().int().min(1000).max(120000).parse(options.companyTimeoutMs);
13796
+ const companyDelayMinMs = z.coerce.number().int().min(0).parse(options.companyDelayMinMs);
13797
+ const companyDelayMaxMs = z.coerce.number().int().min(companyDelayMinMs).parse(options.companyDelayMaxMs);
13798
+ const browserRelayPort = options.browserRelayPort == null
13799
+ ? null
13800
+ : z.coerce.number().int().min(0).max(65535).parse(options.browserRelayPort);
13801
+ const maxCompanies = z.coerce.number().int().min(0).max(1000).parse(options.maxCompanies);
13802
+ const companySaveBatchSize = z.coerce.number().int().min(1).max(200).parse(options.companySaveBatchSize);
13803
+ const hunterBatchSize = z.coerce.number().int().min(1).max(150).parse(options.hunterBatchSize);
13804
+ const maxHunterBatches = z.coerce.number().int().min(0).max(100000).parse(options.maxHunterBatches);
13805
+ const session = await requireAuthSession();
13806
+ const startedAt = new Date().toISOString();
13807
+ const initialStatus = await getCliImportEnrichmentStatusViaApp(session, runIds);
13808
+ if (options.dryRun) {
13809
+ const companyCandidates = options.hunterOnly
13810
+ ? []
13811
+ : (await getCliImportCompanyCandidatesViaApp(session, {
13812
+ runIds,
13813
+ limit: 1000,
13814
+ retry: options.retryCompanies
13815
+ })).companies.slice(0, maxCompanies || undefined);
13816
+ return {
13817
+ status: "ok",
13818
+ dryRun: true,
13819
+ runIds,
13820
+ initial: initialStatus,
13821
+ companyCandidates: companyCandidates.length,
13822
+ next: options.companiesOnly
13823
+ ? "company enrichment only"
13824
+ : options.hunterOnly
13825
+ ? "Hunter enrichment only"
13826
+ : "company profiles, verified domains, then Hunter"
13827
+ };
13828
+ }
13829
+ let companies = {
13830
+ selected: 0,
13831
+ saved: 0,
13832
+ enriched: 0,
13833
+ domains: 0,
13834
+ notFound: 0,
13835
+ failed: 0
13836
+ };
13837
+ if (!options.hunterOnly) {
13838
+ const companyCandidates = (await getCliImportCompanyCandidatesViaApp(session, {
13839
+ runIds,
13840
+ limit: 1000,
13841
+ retry: options.retryCompanies
13842
+ })).companies.slice(0, maxCompanies || undefined);
13843
+ companies.selected = companyCandidates.length;
13844
+ if (companyCandidates.length > 0) {
13845
+ const browserRelay = browserRelayPort == null
13846
+ ? null
13847
+ : await createLocalAccountSearchBrowserRelay(browserRelayPort);
13848
+ try {
13849
+ const config = browserRelay ? null : await readLinkedInDirectLookupConfig();
13850
+ let pendingProfiles = [];
13851
+ const flushCompanyProfiles = async () => {
13852
+ while (pendingProfiles.length > 0) {
13853
+ const batch = pendingProfiles.splice(0, companySaveBatchSize);
13854
+ const saved = await saveCliImportCompanyProfilesViaApp(session, batch);
13855
+ companies = {
13856
+ ...companies,
13857
+ saved: companies.saved + saved.saved,
13858
+ enriched: companies.enriched + saved.enriched,
13859
+ domains: companies.domains + saved.domains,
13860
+ notFound: companies.notFound + saved.notFound,
13861
+ failed: companies.failed + saved.failed
13862
+ };
13863
+ writeProgress(`Company enrichment: ${companies.saved}/${companyCandidates.length} saved, ${companies.domains} domains.`);
13864
+ }
13865
+ };
13866
+ const detailCache = new Map();
13867
+ const companyIdsWithDetails = new Set();
13868
+ const enrichResolvedCompanies = async (resolutions) => {
13869
+ const resolutionBatchSize = 20;
13870
+ for (let offset = 0; offset < resolutions.length; offset += resolutionBatchSize) {
13871
+ const resolutionBatch = resolutions.slice(offset, offset + resolutionBatchSize);
13872
+ const ids = Array.from(new Set(resolutionBatch
13873
+ .map((resolution) => resolution.resolvedCompanyId)
13874
+ .filter((value) => Boolean(value)))).filter((companyId) => !detailCache.has(companyId));
13875
+ if (ids.length > 0) {
13876
+ const response = await fetchCliImportSalesNavigatorJson({
13877
+ url: buildCliImportCompanyDetailsUrl(ids),
13878
+ config,
13879
+ browserRelay,
13880
+ timeoutMs: companyTimeoutMs,
13881
+ label: `Sales Navigator company details ${companies.saved + 1}-${companies.saved + resolutionBatch.length}`,
13882
+ companyId: ids[0] ?? null,
13883
+ });
13884
+ const results = cliImportCompanyRecord(response.body?.results) ?? {};
13885
+ const errors = cliImportCompanyRecord(response.body?.errors) ?? {};
13886
+ const statuses = cliImportCompanyRecord(response.body?.statuses) ?? {};
13887
+ for (const companyId of ids) {
13888
+ const detail = cliImportCompanyRecord(results[companyId]);
13889
+ const statusValue = statuses[companyId];
13890
+ const status = typeof statusValue === "number"
13891
+ ? statusValue
13892
+ : typeof statusValue === "string" && /^\d+$/.test(statusValue)
13893
+ ? Number(statusValue)
13894
+ : response.status;
13895
+ const errorValue = errors[companyId];
13896
+ detailCache.set(companyId, {
13897
+ detail,
13898
+ status,
13899
+ error: errorValue
13900
+ ? JSON.stringify(errorValue).slice(0, 3000)
13901
+ : response.status >= 200 && response.status < 300
13902
+ ? null
13903
+ : `Sales Navigator company details returned ${response.status}`,
13904
+ });
13905
+ }
13906
+ }
13907
+ const fallbackResolutions = [];
13908
+ for (let index = 0; index < resolutionBatch.length; index += 1) {
13909
+ const resolution = resolutionBatch[index];
13910
+ const cached = detailCache.get(resolution.resolvedCompanyId ?? "");
13911
+ if (cached?.detail && resolution.resolvedCompanyId) {
13912
+ companyIdsWithDetails.add(resolution.resolvedCompanyId);
13913
+ }
13914
+ if (!cached?.detail &&
13915
+ cached?.status === 404 &&
13916
+ !resolution.accountSearchUsed &&
13917
+ resolution.candidate.companyName) {
13918
+ try {
13919
+ const fallback = await resolveCliImportCompanyIdentity({
13920
+ ...resolution.candidate,
13921
+ companyId: null,
13922
+ salesNavCompanyUrl: null,
13923
+ linkedinCompanyUrl: null,
13924
+ }, config, companyTimeoutMs, browserRelay);
13925
+ if (fallback.resolvedCompanyId) {
13926
+ fallbackResolutions.push(fallback);
13927
+ }
13928
+ else {
13929
+ pendingProfiles.push(buildCliImportCompanyProfile(fallback, null, {
13930
+ status: 404,
13931
+ error: "No exact LinkedIn company was found by Account Search",
13932
+ }));
13933
+ }
13934
+ }
13935
+ catch (error) {
13936
+ if (error instanceof CliImportCompanyRateLimitError ||
13937
+ error instanceof CliImportCompanySessionError) {
13938
+ throw error;
13939
+ }
13940
+ pendingProfiles.push(buildCliImportCompanyProfile(resolution, null, {
13941
+ status: 500,
13942
+ error: error instanceof Error ? error.message : String(error),
13943
+ }));
13944
+ }
13945
+ if (index < resolutionBatch.length - 1 &&
13946
+ companyDelayMaxMs > 0) {
13947
+ await delay(randomIntegerBetween(companyDelayMinMs, companyDelayMaxMs));
13948
+ }
13949
+ continue;
13950
+ }
13951
+ pendingProfiles.push(buildCliImportCompanyProfile(resolution, cached?.detail ?? null, {
13952
+ status: cached?.status ?? 500,
13953
+ error: cached?.error ?? "Sales Navigator returned no company detail",
13954
+ }));
13955
+ }
13956
+ await flushCompanyProfiles();
13957
+ if (fallbackResolutions.length > 0) {
13958
+ await enrichResolvedCompanies(fallbackResolutions);
13959
+ }
13960
+ if (offset + resolutionBatchSize < resolutions.length &&
13961
+ companyDelayMaxMs > 0) {
13962
+ await delay(randomIntegerBetween(companyDelayMinMs, companyDelayMaxMs));
13963
+ }
13964
+ }
13965
+ };
13966
+ const knownCandidates = companyCandidates.filter((candidate) => candidate.companyId && /^\d+$/.test(candidate.companyId));
13967
+ const knownResolutions = await Promise.all(knownCandidates.map((candidate) => resolveCliImportCompanyIdentity(candidate, config, companyTimeoutMs, browserRelay)));
13968
+ await enrichResolvedCompanies(knownResolutions);
13969
+ const knownByAlias = new Map();
13970
+ for (const candidate of knownCandidates) {
13971
+ if (!candidate.companyId ||
13972
+ !companyIdsWithDetails.has(candidate.companyId)) {
13973
+ continue;
13974
+ }
13975
+ for (const alias of [
13976
+ candidate.companyName ?? "",
13977
+ aggressivelyCleanLookupCompanyName(candidate.companyName ?? ""),
13978
+ ]) {
13979
+ const key = normalizeLooseMatchText(alias);
13980
+ if (!key)
13981
+ continue;
13982
+ const entries = knownByAlias.get(key) ?? [];
13983
+ entries.push(candidate);
13984
+ knownByAlias.set(key, entries);
13985
+ }
13986
+ }
13987
+ const unresolvedCandidates = [];
13988
+ const localAliasResolutions = [];
13989
+ for (const candidate of companyCandidates) {
13990
+ if (candidate.companyId && /^\d+$/.test(candidate.companyId))
13991
+ continue;
13992
+ const matches = new Map();
13993
+ for (const alias of [
13994
+ candidate.companyName ?? "",
13995
+ aggressivelyCleanLookupCompanyName(candidate.companyName ?? ""),
13996
+ ]) {
13997
+ const key = normalizeLooseMatchText(alias);
13998
+ for (const match of knownByAlias.get(key) ?? []) {
13999
+ if (match.companyId)
14000
+ matches.set(match.companyId, match);
14001
+ }
14002
+ }
14003
+ if (matches.size === 1) {
14004
+ const match = Array.from(matches.values())[0];
14005
+ localAliasResolutions.push({
14006
+ candidate,
14007
+ resolvedCompanyId: match.companyId,
14008
+ salesNavCompanyUrl: match.salesNavCompanyUrl ??
14009
+ `https://www.linkedin.com/sales/company/${match.companyId}`,
14010
+ linkedinCompanyUrl: match.linkedinCompanyUrl ??
14011
+ normalizeLinkedInCompanyPage(match.companyId),
14012
+ matchedCompanyName: match.companyName,
14013
+ accountSearchUsed: false,
14014
+ account: null,
14015
+ });
14016
+ }
14017
+ else {
14018
+ unresolvedCandidates.push(candidate);
14019
+ }
14020
+ }
14021
+ await enrichResolvedCompanies(localAliasResolutions);
14022
+ writeProgress(`Company identities: ${knownResolutions.length} imported IDs, ${localAliasResolutions.length} local aliases, ${unresolvedCandidates.length} Account Search candidates.`);
14023
+ const accountSearchGroupSize = 5;
14024
+ let accountSearches = 0;
14025
+ for (let offset = 0; offset < unresolvedCandidates.length; offset += accountSearchGroupSize) {
14026
+ const group = unresolvedCandidates.slice(offset, offset + accountSearchGroupSize);
14027
+ const resolvedGroup = [];
14028
+ for (let index = 0; index < group.length; index += 1) {
14029
+ const candidate = group[index];
14030
+ let resolution = null;
14031
+ try {
14032
+ resolution = await resolveCliImportCompanyIdentity(candidate, config, companyTimeoutMs, browserRelay);
14033
+ }
14034
+ catch (error) {
14035
+ if (error instanceof CliImportCompanyRateLimitError ||
14036
+ error instanceof CliImportCompanySessionError) {
14037
+ throw error;
14038
+ }
14039
+ pendingProfiles.push(buildCliImportCompanyProfile({
14040
+ candidate,
14041
+ resolvedCompanyId: null,
14042
+ salesNavCompanyUrl: candidate.salesNavCompanyUrl,
14043
+ linkedinCompanyUrl: candidate.linkedinCompanyUrl,
14044
+ matchedCompanyName: null,
14045
+ accountSearchUsed: true,
14046
+ account: null,
14047
+ }, null, {
14048
+ status: 500,
14049
+ error: error instanceof Error
14050
+ ? error.message
14051
+ : String(error),
14052
+ }));
14053
+ }
14054
+ accountSearches += 1;
14055
+ if (resolution?.resolvedCompanyId) {
14056
+ resolvedGroup.push(resolution);
14057
+ }
14058
+ else if (resolution) {
14059
+ pendingProfiles.push(buildCliImportCompanyProfile(resolution, null, {
14060
+ status: 404,
14061
+ error: "No exact LinkedIn company was found by Account Search",
14062
+ }));
14063
+ }
14064
+ if (index < group.length - 1 &&
14065
+ companyDelayMaxMs > 0) {
14066
+ await delay(randomIntegerBetween(companyDelayMinMs, companyDelayMaxMs));
14067
+ }
14068
+ }
14069
+ await flushCompanyProfiles();
14070
+ await enrichResolvedCompanies(resolvedGroup);
14071
+ writeProgress(`Account Search: ${Math.min(offset + group.length, unresolvedCandidates.length)}/${unresolvedCandidates.length} checked (${accountSearches} requests).`);
14072
+ }
14073
+ }
14074
+ finally {
14075
+ await browserRelay?.close();
14076
+ }
14077
+ }
14078
+ }
14079
+ let hunter = {
14080
+ batches: 0,
14081
+ selected: 0,
14082
+ processed: 0,
14083
+ found: 0,
14084
+ notFound: 0,
14085
+ failed: 0
14086
+ };
14087
+ if (!options.companiesOnly) {
14088
+ const retryBefore = options.retryEmails ? startedAt : null;
14089
+ while (maxHunterBatches === 0 || hunter.batches < maxHunterBatches) {
14090
+ const batch = await runCliImportHunterBatchViaApp(session, {
14091
+ runIds,
14092
+ limit: hunterBatchSize,
14093
+ retryBefore
14094
+ });
14095
+ if (batch.selected === 0)
14096
+ break;
14097
+ hunter = {
14098
+ batches: hunter.batches + 1,
14099
+ selected: hunter.selected + batch.selected,
14100
+ processed: hunter.processed + batch.processed,
14101
+ found: hunter.found + batch.found,
14102
+ notFound: hunter.notFound + batch.notFound,
14103
+ failed: hunter.failed + batch.failed
14104
+ };
14105
+ writeProgress(`Hunter enrichment: ${hunter.processed} checked, ${hunter.found} emails found.`);
14106
+ if (batch.halted) {
14107
+ throw new Error(`Hunter enrichment stopped: ${batch.error ?? "provider unavailable"}. Saved progress will be reused.`);
14108
+ }
14109
+ }
14110
+ }
14111
+ const finalStatus = await getCliImportEnrichmentStatusViaApp(session, runIds);
14112
+ const payload = {
14113
+ status: "ok",
14114
+ dryRun: false,
14115
+ runIds,
14116
+ companies,
14117
+ hunter,
14118
+ before: initialStatus,
14119
+ after: finalStatus,
14120
+ outreachStarted: false,
14121
+ workspaceUrl: `${session.apiBaseUrl}/leads/cli-imports?runId=${encodeURIComponent(runIds[0])}`
14122
+ };
14123
+ if (options.out)
14124
+ await writeJsonFile(options.out, payload);
14125
+ return payload;
14126
+ }
13346
14127
  async function runAffiliateLaunchCommand(options) {
13347
14128
  const affiliateLink = z.string().url().parse(options.affiliateLink);
13348
14129
  const linkedInUrl = z.string().url().parse(options.linkedinUrl);
@@ -13444,6 +14225,28 @@ program
13444
14225
  .action(async (options) => {
13445
14226
  printOutput(await runSalesNavigatorPeopleCollectCommand(options));
13446
14227
  });
14228
+ program
14229
+ .command("salesnav:people:enrich")
14230
+ .alias("leads:enrich-import")
14231
+ .description("Enrich stored CLI imports with company profiles, verified domains, and Hunter emails.")
14232
+ .requiredOption("--run-id <uuid>", "Workspace import run id; repeat the option or pass comma-separated ids", collectRunId, [])
14233
+ .option("--browser-relay-port <number>", "Use the signed-in browser for Account Search and company-detail requests")
14234
+ .option("--company-timeout-ms <number>", "Timeout for each company lookup", "20000")
14235
+ .option("--company-delay-min-ms <number>", "Minimum delay between LinkedIn company lookups", "1500")
14236
+ .option("--company-delay-max-ms <number>", "Maximum delay between LinkedIn company lookups", "3000")
14237
+ .option("--max-companies <number>", "Maximum companies for this invocation; 0 processes all eligible companies", "0")
14238
+ .option("--company-save-batch-size <number>", "Company rows saved per request", "50")
14239
+ .option("--hunter-batch-size <number>", "Hunter contacts checked per request", "100")
14240
+ .option("--max-hunter-batches <number>", "Maximum Hunter batches; 0 continues until no eligible people remain", "0")
14241
+ .option("--companies-only", "Stop after company profiles and domains", false)
14242
+ .option("--hunter-only", "Skip company lookup and use already stored domains", false)
14243
+ .option("--retry-companies", "Retry previously saved company failures", false)
14244
+ .option("--retry-emails", "Retry previously checked unresolved emails once", false)
14245
+ .option("--dry-run", "Show scope without LinkedIn or Hunter requests", false)
14246
+ .option("--out <path>", "Optional JSON result path")
14247
+ .action(async (options) => {
14248
+ printOutput(await runSalesNavigatorImportEnrichmentCommand(options));
14249
+ });
13447
14250
  addAffiliateAudienceOptions(program
13448
14251
  .command("affiliate:launch")
13449
14252
  .description("Build an affiliate audience from an affiliate link and LinkedIn search URL."))
@@ -135,8 +135,20 @@ export function parseLinkedInCompanyPage(html, requestUrl) {
135
135
  normalizeWhitespace($("h1").first().text()) ||
136
136
  normalizeWhitespace($('meta[property="og:title"]').attr("content")) ||
137
137
  undefined;
138
- const website = normalizeWhitespace(String(jsonLd?.url ?? "")) ||
139
- normalizeWhitespace(definitions.get("website")) ||
138
+ const jsonLdUrl = normalizeWhitespace(String(jsonLd?.url ?? ""));
139
+ const jsonLdWebsite = (() => {
140
+ if (!jsonLdUrl)
141
+ return undefined;
142
+ try {
143
+ const hostname = new URL(jsonLdUrl).hostname;
144
+ return /(^|\.)linkedin\.com$/i.test(hostname) ? undefined : jsonLdUrl;
145
+ }
146
+ catch {
147
+ return undefined;
148
+ }
149
+ })();
150
+ const website = normalizeWhitespace(definitions.get("website")) ||
151
+ jsonLdWebsite ||
140
152
  undefined;
141
153
  const description = normalizeWhitespace(String(jsonLd?.description ?? "")) ||
142
154
  normalizeWhitespace($('meta[name="description"]').attr("content")) ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
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",