salesprompter-cli 0.1.25 → 0.1.26

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/dist/cli.js CHANGED
@@ -19,11 +19,13 @@ import { buildDeelLeadPoolContactSql, buildDeelOutreachExportSql, buildDeelOutre
19
19
  import { buildDirectPathLeadExportSql, normalizeDirectPathRows, segmentDirectPathRows } from "./direct-path.js";
20
20
  import { AccountLeadProvider, DryRunSyncProvider, HeuristicCompanyProvider, HeuristicEnrichmentProvider, HeuristicPeopleSearchProvider, HeuristicScoringProvider, RoutedSyncProvider } from "./engine.js";
21
21
  import { analyzeHistoricalQueries } from "./historical-queries.js";
22
+ import { buildHunterEmailfinderQueueSql, buildHunterEmailfinderTriggerPayload, directEmailEnrichmentInputRowArraySchema, hunterEmailfinderQueueRowArraySchema, parseDirectEmailEnrichmentInput, readHunterEmailfinderConfig, resolveDirectEmailEnrichmentClientId, triggerHunterEmailfinderWorkflow } from "./hunter-emailfinder.js";
22
23
  import { buildHistoricalVendorIcp, buildVendorIcp } from "./icp-templates.js";
23
24
  import { InstantlySyncProvider } from "./instantly.js";
24
25
  import { backfillLinkedInCompanies } from "./linkedin-companies.js";
26
+ import { parseLinkedInCompanyPage } from "./linkedin-companies.js";
25
27
  import { crawlLinkedInProductCategory } from "./linkedin-products.js";
26
- import { claimValidatedSalesNavigatorSessionCookieForCli } from "./linkedin-session.js";
28
+ import { claimValidatedSalesNavigatorSessionCookieForCli, createLinkedInSessionSupabaseClient } from "./linkedin-session.js";
27
29
  import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
28
30
  import { readJsonFile, splitCsv, writeJsonFile, writeTextFile } from "./io.js";
29
31
  import { buildSalesNavigatorCrawlPreview, createSalesNavigatorCrawlSeed, DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS, buildSalesNavigatorPeopleSlice, deriveSalesNavigatorTitleQuerySeeds, expandSalesNavigatorCrawlAttempt, SalesNavigatorSliceTooBroadError } from "./sales-navigator.js";
@@ -78,6 +80,16 @@ const LinkedInCompanyBackfillStatusResponseSchema = z.object({
78
80
  processed: z.boolean(),
79
81
  remaining: z.number().int().nonnegative()
80
82
  });
83
+ const CliEmailEnrichmentCompaniesResponseSchema = z.object({
84
+ clientId: z.number().int().positive(),
85
+ companies: z.array(z.object({
86
+ companyId: z.string().min(1),
87
+ companyName: z.string().min(1),
88
+ companyNameCleaned: z.string().nullable().optional(),
89
+ domain: z.string().nullable(),
90
+ linkedinCompanyPage: z.string().nullable()
91
+ }))
92
+ });
81
93
  const SalesNavigatorLaunchDiagnosticsSchema = z.object({
82
94
  orderedCandidateAgentIds: z.array(z.string().min(1)),
83
95
  runningAgentIds: z.array(z.string().min(1)),
@@ -238,6 +250,7 @@ const SalesNavigatorCrawlReportResponseSchema = z.object({
238
250
  });
239
251
  const helpAliasByCommandName = new Map([
240
252
  ["contacts:find-linkedin-urls", "contacts:resolve-profiles"],
253
+ ["contacts:process-emails", "contacts:resolve-emails"],
241
254
  ["linkedin-companies:backfill", "companies:enrich"],
242
255
  ["linkedin-products:scrape", "market:scrape"],
243
256
  ["salesnav:from-product-category", "leads:discover"],
@@ -252,6 +265,7 @@ const helpVisibleCommandNames = new Set([
252
265
  "auth:whoami",
253
266
  "llm:ready",
254
267
  "contacts:find-linkedin-urls",
268
+ "contacts:process-emails",
255
269
  "auth:logout",
256
270
  "account:resolve",
257
271
  "icp:define",
@@ -498,6 +512,296 @@ function normalizeLookupCompanyForSearch(value) {
498
512
  .replace(/\s+/g, " ")
499
513
  .trim();
500
514
  }
515
+ function normalizeLookupCompanyForCleaning(value) {
516
+ return normalizeLookupWhitespace(value)
517
+ .replace(/[+]/g, " ")
518
+ .replace(/\s+/g, " ")
519
+ .trim();
520
+ }
521
+ function stripLookupCompanyLegalSuffixes(value) {
522
+ let cleaned = normalizeLookupCompanyForCleaning(value);
523
+ if (!cleaned) {
524
+ return "";
525
+ }
526
+ cleaned = cleaned.replace(/\s*\([^)]*\)\s*$/g, "").trim();
527
+ const suffixes = [
528
+ "gmbh & co. kgaa",
529
+ "gmbh & co kgaa",
530
+ "gmbh & co. kg",
531
+ "gmbh & co kg",
532
+ "ug (haftungsbeschrankt)",
533
+ "ug (haftungsbeschränkt)",
534
+ "wirtschaftsprufungsgesellschaft",
535
+ "wirtschaftsprüfungsgesellschaft",
536
+ "gesellschaft",
537
+ "corporation",
538
+ "holdings",
539
+ "holding",
540
+ "international",
541
+ "services",
542
+ "service",
543
+ "solutions",
544
+ "systems",
545
+ "consulting",
546
+ "management",
547
+ "technologies",
548
+ "technology",
549
+ "digital",
550
+ "software",
551
+ "global",
552
+ "worldwide",
553
+ "deutschland",
554
+ "germany",
555
+ "america",
556
+ "europe",
557
+ "company",
558
+ "verlag",
559
+ "servtec",
560
+ "group",
561
+ "gruppe",
562
+ "tech",
563
+ "gmbh",
564
+ "kgaa",
565
+ "mbh",
566
+ "ohg",
567
+ "kg",
568
+ "ag",
569
+ "ug",
570
+ "se",
571
+ "sa",
572
+ "s.a.",
573
+ "sarl",
574
+ "s.r.l.",
575
+ "srl",
576
+ "llc",
577
+ "ltd",
578
+ "limited",
579
+ "inc.",
580
+ "inc",
581
+ "corp.",
582
+ "corp",
583
+ "co.",
584
+ "co",
585
+ "plc",
586
+ "bv",
587
+ "b.v.",
588
+ "nv",
589
+ "n.v.",
590
+ "oyj",
591
+ "oy",
592
+ "aps",
593
+ "a/s",
594
+ "ab",
595
+ "as"
596
+ ];
597
+ const escapedSuffixes = suffixes
598
+ .map((suffix) => suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
599
+ .sort((left, right) => right.length - left.length)
600
+ .join("|");
601
+ const separatorPattern = String.raw `(?:\s*[,|/]\s*|\s+[-–]\s+|\s+)`;
602
+ const trailingSuffixPattern = new RegExp(`${separatorPattern}(?:${escapedSuffixes})\\s*$`, "i");
603
+ const suffixBeforeSeparatorPattern = new RegExp(`\\s+(?:${escapedSuffixes})(?=\\s*[-–|/])`, "ig");
604
+ for (let index = 0; index < 6; index += 1) {
605
+ const next = cleaned
606
+ .replace(suffixBeforeSeparatorPattern, "")
607
+ .replace(trailingSuffixPattern, "")
608
+ .trim();
609
+ if (next === cleaned) {
610
+ break;
611
+ }
612
+ cleaned = next;
613
+ }
614
+ return normalizeLookupWhitespace(cleaned);
615
+ }
616
+ function aggressivelyCleanLookupCompanyName(value) {
617
+ let cleaned = stripLookupCompanyLegalSuffixes(value);
618
+ if (!cleaned) {
619
+ return "";
620
+ }
621
+ const suffixesToRemove = [
622
+ "group",
623
+ "gruppe",
624
+ "international",
625
+ "global",
626
+ "worldwide",
627
+ "services",
628
+ "service",
629
+ "servtec",
630
+ "solutions",
631
+ "systems",
632
+ "consulting",
633
+ "management",
634
+ "technologies",
635
+ "technology",
636
+ "digital",
637
+ "tech",
638
+ "it",
639
+ "software",
640
+ "europe",
641
+ "deutschland",
642
+ "germany",
643
+ "usa",
644
+ "uk",
645
+ "america",
646
+ "holdings",
647
+ "holding",
648
+ "corporation",
649
+ "company",
650
+ "verlag",
651
+ "gesellschaft",
652
+ "wirtschaftsprüfungsgesellschaft",
653
+ "wirtschaftsprufungsgesellschaft"
654
+ ];
655
+ for (const suffix of suffixesToRemove) {
656
+ const pattern = new RegExp(`(?:\\s+|\\s*[-–|/]\\s*)${suffix}\\s*$`, "i");
657
+ cleaned = cleaned.replace(pattern, "").trim();
658
+ }
659
+ const segments = cleaned
660
+ .split(/\s*[-–|/]\s*/)
661
+ .map((segment) => normalizeLookupWhitespace(segment))
662
+ .filter(Boolean);
663
+ if (segments.length > 1) {
664
+ const descriptorPattern = /^(robotic|solutions?|systems?|services?|service|consulting|management|technologies?|technology|digital|tech|software|logistics|distribution|distributions|trading|handel|messtechnik|produktionstechnik|umwelttechnik|reinigungstechnik|pflanzenschutz|betonwerk|export|import|export import|vertrieb|wholesale|retail|agro|center|centre|center)$/i;
665
+ const trailingSegments = segments.slice(1);
666
+ const shouldCollapseToFirstSegment = trailingSegments.every((segment) => segment
667
+ .split(/\s+/)
668
+ .filter(Boolean)
669
+ .every((word) => descriptorPattern.test(word)));
670
+ if (shouldCollapseToFirstSegment) {
671
+ cleaned = segments[0] ?? cleaned;
672
+ }
673
+ }
674
+ const words = cleaned.split(/\s+/).filter(Boolean);
675
+ if (words.length === 2) {
676
+ const [firstWord, secondWord] = words;
677
+ if (/^(servtec|digital|tech|solutions|systems|services|consulting|management|technologies)$/i.test(secondWord ?? "")) {
678
+ cleaned = firstWord ?? cleaned;
679
+ }
680
+ }
681
+ const descriptorTailPattern = /^(robotic|solutions?|systems?|services?|service|consulting|management|technologies?|technology|digital|tech|software|logistics|distribution|distributions|trading|handel)$/i;
682
+ const cleanedWords = cleaned.split(/\s+/).filter(Boolean);
683
+ if (cleanedWords.length >= 2 &&
684
+ cleanedWords.slice(1).every((word) => descriptorTailPattern.test(word))) {
685
+ cleaned = cleanedWords[0] ?? cleaned;
686
+ }
687
+ return normalizeLookupWhitespace(cleaned);
688
+ }
689
+ function resolveCompanyCleaningMode(value) {
690
+ const normalized = String(value ?? "basic").trim().toLowerCase();
691
+ if (normalized === "off" || normalized === "raw" || normalized === "none") {
692
+ return "off";
693
+ }
694
+ if (normalized === "ai" || normalized === "openai") {
695
+ return "ai";
696
+ }
697
+ return "basic";
698
+ }
699
+ function readOpenAiCompanyCleanerConfig() {
700
+ const apiKey = process.env.SALESPROMPTER_OPENAI_API_KEY?.trim() ||
701
+ process.env.OPENAI_API_KEY?.trim() ||
702
+ "";
703
+ if (!apiKey) {
704
+ throw new Error("Missing OpenAI API key for AI company cleaning. Set OPENAI_API_KEY or SALESPROMPTER_OPENAI_API_KEY.");
705
+ }
706
+ const baseUrl = process.env.SALESPROMPTER_OPENAI_BASE_URL?.trim().replace(/\/+$/, "") || "https://api.openai.com/v1";
707
+ const promptId = process.env.SALESPROMPTER_COMPANY_CLEANER_PROMPT_ID?.trim() ||
708
+ "pmpt_69c164c582a48196b288a8909de87c700694068fbd1b0e55";
709
+ const promptVersion = process.env.SALESPROMPTER_COMPANY_CLEANER_PROMPT_VERSION?.trim() || "2";
710
+ const maxOutputTokens = z.coerce
711
+ .number()
712
+ .int()
713
+ .min(256)
714
+ .max(32768)
715
+ .parse(process.env.SALESPROMPTER_COMPANY_CLEANER_MAX_OUTPUT_TOKENS ?? "4096");
716
+ return {
717
+ apiKey,
718
+ baseUrl,
719
+ promptId,
720
+ promptVersion,
721
+ maxOutputTokens
722
+ };
723
+ }
724
+ async function cleanCompanyNamesWithOpenAi(companyNames, config) {
725
+ if (companyNames.length === 0) {
726
+ return new Map();
727
+ }
728
+ const response = await fetch(`${config.baseUrl}/responses`, {
729
+ method: "POST",
730
+ headers: {
731
+ Authorization: `Bearer ${config.apiKey}`,
732
+ "Content-Type": "application/json"
733
+ },
734
+ body: JSON.stringify({
735
+ prompt: {
736
+ id: config.promptId,
737
+ version: config.promptVersion
738
+ },
739
+ input: [
740
+ {
741
+ role: "user",
742
+ content: JSON.stringify(companyNames)
743
+ }
744
+ ],
745
+ text: {
746
+ format: {
747
+ type: "text"
748
+ }
749
+ },
750
+ reasoning: {},
751
+ max_output_tokens: config.maxOutputTokens,
752
+ store: true
753
+ })
754
+ });
755
+ if (!response.ok) {
756
+ const details = await response.text();
757
+ throw new Error(`OpenAI company cleaning failed with ${response.status}: ${details}`);
758
+ }
759
+ const payload = (await response.json());
760
+ const incomplete = payload.status === "incomplete" || payload.incomplete_details;
761
+ if (incomplete) {
762
+ const reason = payload.incomplete_details?.reason || payload.status || "unknown";
763
+ throw new Error(`OpenAI company cleaning returned an incomplete response (${reason}).`);
764
+ }
765
+ const rawText = payload.output_text ??
766
+ payload.output?.flatMap((item) => item.content ?? []).map((item) => item.text ?? "").join("") ??
767
+ "";
768
+ if (!rawText.trim()) {
769
+ throw new Error("OpenAI company cleaning returned no text.");
770
+ }
771
+ const parsed = z
772
+ .array(z.object({
773
+ companyName_input: z.string(),
774
+ companyName_cleaned: z.string().nullish()
775
+ }))
776
+ .parse(JSON.parse(rawText));
777
+ const cleanedByInput = new Map();
778
+ for (const item of parsed) {
779
+ cleanedByInput.set(normalizeLookupCompanyForCleaning(item.companyName_input), aggressivelyCleanLookupCompanyName(item.companyName_cleaned ?? item.companyName_input));
780
+ }
781
+ return cleanedByInput;
782
+ }
783
+ async function buildCompanyNameCleaningMap(rows, mode) {
784
+ const uniqueCompanyNames = Array.from(new Set(rows
785
+ .map((row) => normalizeLookupCompanyForCleaning(row.companyName))
786
+ .filter((value) => value.length > 0)));
787
+ if (uniqueCompanyNames.length === 0) {
788
+ return new Map();
789
+ }
790
+ const cleanedByInput = new Map();
791
+ for (const companyName of uniqueCompanyNames) {
792
+ cleanedByInput.set(companyName, aggressivelyCleanLookupCompanyName(companyName));
793
+ }
794
+ if (mode !== "ai") {
795
+ return cleanedByInput;
796
+ }
797
+ const aiCleanedByInput = await cleanCompanyNamesWithOpenAi(uniqueCompanyNames, readOpenAiCompanyCleanerConfig());
798
+ for (const [input, cleaned] of aiCleanedByInput.entries()) {
799
+ if (cleaned) {
800
+ cleanedByInput.set(input, cleaned);
801
+ }
802
+ }
803
+ return cleanedByInput;
804
+ }
501
805
  function splitLookupFullName(fullName) {
502
806
  const parts = normalizeLookupWhitespace(fullName).split(" ").filter(Boolean);
503
807
  return {
@@ -581,12 +885,12 @@ function parseLinkedInUrlLookupInput(content) {
581
885
  }))
582
886
  .filter((row) => row.fullName.length > 0 || row.companyName.length > 0);
583
887
  }
584
- function toLinkedInUrlLookupContacts(rows) {
888
+ function toLinkedInUrlLookupContacts(rows, cleanedCompanyMap = new Map()) {
585
889
  return rows.flatMap((row, index) => {
586
890
  const contactId = String(index + 1);
587
891
  const syntheticEmail = row.email?.trim() || buildSyntheticLookupEmail(contactId);
588
892
  const rawCompanyName = normalizeLookupWhitespace(row.companyName);
589
- const cleanedCompanyName = normalizeLookupCompanyForSearch(rawCompanyName);
893
+ const cleanedCompanyName = normalizeLookupCompanyForSearch(cleanedCompanyMap.get(normalizeLookupCompanyForCleaning(rawCompanyName)) ?? rawCompanyName);
590
894
  const rawFullName = normalizeLookupWhitespace(row.fullName);
591
895
  if (looksLikeLookupCompanyRow(rawFullName, rawCompanyName)) {
592
896
  return [
@@ -652,45 +956,265 @@ function deriveCsrfTokenFromCookie(cookie) {
652
956
  const match = cookie.match(/JSESSIONID="?([^";]+)"?/i);
653
957
  return match?.[1]?.trim() || "";
654
958
  }
655
- function readLinkedInDirectLookupConfig() {
656
- const csrfToken = process.env.SALESPROMPTER_LINKEDIN_CSRF_TOKEN?.trim() ||
657
- process.env.LINKEDIN_CSRF_TOKEN?.trim() ||
658
- deriveCsrfTokenFromCookie(process.env.SALESPROMPTER_LINKEDIN_SALES_NAV_COOKIE?.trim() ||
659
- process.env.LINKEDIN_SALES_NAV_COOKIE?.trim() ||
660
- "");
661
- const identity = process.env.SALESPROMPTER_LINKEDIN_X_LI_IDENTITY?.trim() ||
662
- process.env.LINKEDIN_X_LI_IDENTITY?.trim() ||
663
- "";
959
+ function readLinkedInDirectLookupEnvConfig() {
664
960
  const cookie = process.env.SALESPROMPTER_LINKEDIN_SALES_NAV_COOKIE?.trim() ||
665
961
  process.env.LINKEDIN_SALES_NAV_COOKIE?.trim() ||
666
962
  "";
667
- const userAgent = process.env.SALESPROMPTER_LINKEDIN_USER_AGENT?.trim() ||
668
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
963
+ const identity = process.env.SALESPROMPTER_LINKEDIN_X_LI_IDENTITY?.trim() ||
964
+ process.env.LINKEDIN_X_LI_IDENTITY?.trim() ||
965
+ "";
966
+ const csrfToken = process.env.SALESPROMPTER_LINKEDIN_CSRF_TOKEN?.trim() ||
967
+ process.env.LINKEDIN_CSRF_TOKEN?.trim() ||
968
+ deriveCsrfTokenFromCookie(cookie);
669
969
  if (!csrfToken || !identity || !cookie) {
670
- throw new Error("Missing LinkedIn direct lookup tokens. Set LINKEDIN_CSRF_TOKEN, LINKEDIN_X_LI_IDENTITY, and LINKEDIN_SALES_NAV_COOKIE.");
970
+ return null;
671
971
  }
672
972
  return {
673
973
  csrfToken,
674
974
  identity,
675
975
  cookie,
976
+ userAgent: process.env.SALESPROMPTER_LINKEDIN_USER_AGENT?.trim() ||
977
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
978
+ };
979
+ }
980
+ function extractStoredLinkedInLookupValue(metadata, key) {
981
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
982
+ return "";
983
+ }
984
+ const record = metadata;
985
+ const linkedInSession = record.linkedInSession && typeof record.linkedInSession === "object" && !Array.isArray(record.linkedInSession)
986
+ ? record.linkedInSession
987
+ : null;
988
+ const value = linkedInSession?.[key];
989
+ return typeof value === "string" ? value.trim() : "";
990
+ }
991
+ async function readStoredLinkedInDirectLookupConfig() {
992
+ const supabase = createLinkedInSessionSupabaseClient();
993
+ if (!supabase) {
994
+ return null;
995
+ }
996
+ const claimed = await claimValidatedSalesNavigatorSessionCookieForCli({
997
+ queryUrl: "https://www.linkedin.com/sales/search/people",
998
+ source: "cli_direct_lookup",
999
+ supabase
1000
+ });
1001
+ if (!claimed) {
1002
+ return null;
1003
+ }
1004
+ const lookupResult = await supabase
1005
+ .from("linkedin_session_cookies")
1006
+ .select("metadata")
1007
+ .eq("session_cookie_sha256", claimed.sessionCookieSha256)
1008
+ .maybeSingle();
1009
+ if (lookupResult.error && !/linkedin_session_cookies/.test(lookupResult.error.message)) {
1010
+ throw new Error(`Failed to read stored LinkedIn session metadata: ${lookupResult.error.message}`);
1011
+ }
1012
+ const metadata = lookupResult.data?.metadata;
1013
+ const identity = extractStoredLinkedInLookupValue(metadata, "linkedInIdentity");
1014
+ const csrfToken = extractStoredLinkedInLookupValue(metadata, "csrfToken") ||
1015
+ deriveCsrfTokenFromCookie(claimed.sessionCookie);
1016
+ const userAgent = process.env.SALESPROMPTER_LINKEDIN_USER_AGENT?.trim() ||
1017
+ extractStoredLinkedInLookupValue(metadata, "userAgent") ||
1018
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
1019
+ if (!identity || !csrfToken) {
1020
+ return null;
1021
+ }
1022
+ return {
1023
+ csrfToken,
1024
+ identity,
1025
+ cookie: claimed.sessionCookie,
676
1026
  userAgent
677
1027
  };
678
1028
  }
1029
+ let cachedLinkedInDirectLookupConfig = null;
1030
+ async function readLinkedInDirectLookupConfig() {
1031
+ if (cachedLinkedInDirectLookupConfig) {
1032
+ return cachedLinkedInDirectLookupConfig;
1033
+ }
1034
+ const envConfig = readLinkedInDirectLookupEnvConfig();
1035
+ if (envConfig) {
1036
+ cachedLinkedInDirectLookupConfig = envConfig;
1037
+ return envConfig;
1038
+ }
1039
+ const storedConfig = await readStoredLinkedInDirectLookupConfig();
1040
+ if (storedConfig) {
1041
+ cachedLinkedInDirectLookupConfig = storedConfig;
1042
+ return storedConfig;
1043
+ }
1044
+ throw new Error("Missing LinkedIn direct lookup session. Set LINKEDIN_CSRF_TOKEN, LINKEDIN_X_LI_IDENTITY, and LINKEDIN_SALES_NAV_COOKIE, or connect the Salesprompter Chrome extension to sync your LinkedIn session to salesprompter.ai.");
1045
+ }
679
1046
  function generateLinkedInSessionId() {
680
1047
  return Array.from({ length: 22 }, () => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".charAt(Math.floor(Math.random() * 62))).join("");
681
1048
  }
682
- function buildLinkedInSalesApiUrl(firstName, lastName, companyName) {
1049
+ function buildLinkedInSalesApiUrl(params) {
683
1050
  const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
684
1051
  "https://www.linkedin.com";
685
- return `${baseUrl.replace(/\/+$/, "")}/sales-api/salesApiLeadSearch?q=searchQuery&query=(recentSearchParam:(id:${Date.now()},doLogHistory:true),filters:List((type:FIRST_NAME,values:List((text:${encodeURIComponent(firstName)},selectionType:INCLUDED))),(type:LAST_NAME,values:List((text:${encodeURIComponent(lastName)},selectionType:INCLUDED))),(type:CURRENT_COMPANY,values:List((text:${encodeURIComponent(companyName)},selectionType:INCLUDED)))))&start=0&count=25&trackingParam=(sessionId:${generateLinkedInSessionId()})&decorationId=com.linkedin.sales.deco.desktop.searchv2.LeadSearchResult-14`;
1052
+ const encodedFirstName = encodeURIComponent(params.firstName);
1053
+ const encodedLastName = encodeURIComponent(params.lastName);
1054
+ const encodedCompanyName = encodeURIComponent(params.companyName);
1055
+ const filters = params.searchMode === "current_company"
1056
+ ? `(type:FIRST_NAME,values:List((text:${encodedFirstName},selectionType:INCLUDED))),(type:LAST_NAME,values:List((text:${encodedLastName},selectionType:INCLUDED))),(type:CURRENT_COMPANY,values:List((text:${encodedCompanyName},selectionType:INCLUDED)))`
1057
+ : `(type:FIRST_NAME,values:List((text:${encodedFirstName},selectionType:INCLUDED))),(type:LAST_NAME,values:List((text:${encodedLastName},selectionType:INCLUDED)))`;
1058
+ const keywordsSegment = params.searchMode === "keywords" ? `,keywords:${encodedCompanyName}` : "";
1059
+ return `${baseUrl.replace(/\/+$/, "")}/sales-api/salesApiLeadSearch?q=searchQuery&query=(recentSearchParam:(id:${Date.now()},doLogHistory:true),filters:List(${filters})${keywordsSegment})&start=0&count=25&trackingParam=(sessionId:${generateLinkedInSessionId()})&decorationId=com.linkedin.sales.deco.desktop.searchv2.LeadSearchResult-14`;
1060
+ }
1061
+ function buildLinkedInAccountSearchApiUrl(companyName) {
1062
+ const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
1063
+ "https://www.linkedin.com";
1064
+ const encodedCompanyName = encodeURIComponent(companyName);
1065
+ return `${baseUrl.replace(/\/+$/, "")}/sales-api/salesApiAccountSearch?q=searchQuery&query=(recentSearchParam:(id:${Date.now()},doLogHistory:true),spellCorrectionEnabled:true,keywords:${encodedCompanyName})&start=0&count=10&trackingParam=(sessionId:${generateLinkedInSessionId()})&decorationId=com.linkedin.sales.deco.desktop.searchv2.AccountSearchResult-14`;
1066
+ }
1067
+ function buildLinkedInLookupSearchVariants(contact) {
1068
+ const variants = [];
1069
+ const seen = new Set();
1070
+ const companyCandidates = [
1071
+ normalizeLookupWhitespace(contact.companyName),
1072
+ normalizeLookupWhitespace(contact.companyNameOriginal)
1073
+ ].filter(Boolean);
1074
+ for (const companyName of companyCandidates) {
1075
+ for (const searchMode of ["current_company", "keywords"]) {
1076
+ const key = [
1077
+ contact.firstName.trim().toLowerCase(),
1078
+ contact.lastName.trim().toLowerCase(),
1079
+ companyName.toLowerCase(),
1080
+ searchMode
1081
+ ].join("|");
1082
+ if (seen.has(key)) {
1083
+ continue;
1084
+ }
1085
+ seen.add(key);
1086
+ variants.push({
1087
+ firstName: contact.firstName,
1088
+ lastName: contact.lastName,
1089
+ companyName,
1090
+ searchMode
1091
+ });
1092
+ }
1093
+ }
1094
+ return variants;
686
1095
  }
687
1096
  function extractLinkedInProfileUrlFromSalesApiElement(element) {
688
1097
  const entityUrn = typeof element?.entityUrn === "string" ? element.entityUrn : "";
689
1098
  const salesIdMatch = entityUrn.match(/\(([^,]+),/);
690
1099
  return salesIdMatch ? `https://www.linkedin.com/in/${salesIdMatch[1]}` : null;
691
1100
  }
1101
+ function collectNestedStrings(value, seen = new Set()) {
1102
+ if (value == null || seen.has(value)) {
1103
+ return [];
1104
+ }
1105
+ if (typeof value === "string") {
1106
+ return [value];
1107
+ }
1108
+ if (typeof value !== "object") {
1109
+ return [];
1110
+ }
1111
+ seen.add(value);
1112
+ if (Array.isArray(value)) {
1113
+ return value.flatMap((entry) => collectNestedStrings(entry, seen));
1114
+ }
1115
+ return Object.values(value).flatMap((entry) => collectNestedStrings(entry, seen));
1116
+ }
1117
+ function extractLinkedInCompanyUrlFromSalesApiElement(element) {
1118
+ if (!element) {
1119
+ return null;
1120
+ }
1121
+ const explicitUrlCandidates = [
1122
+ typeof element.companyPageUrl === "string" ? element.companyPageUrl : null,
1123
+ typeof element.regularCompanyUrl === "string" ? element.regularCompanyUrl : null,
1124
+ typeof element.url === "string" ? element.url : null
1125
+ ].filter(Boolean);
1126
+ const nestedUrlMatch = collectNestedStrings(element).find((value) => /https:\/\/www\.linkedin\.com\/company\/[^/?#]+/i.test(value));
1127
+ if (nestedUrlMatch) {
1128
+ return nestedUrlMatch.match(/https:\/\/www\.linkedin\.com\/company\/[^/?#]+/i)?.[0] ?? null;
1129
+ }
1130
+ for (const candidate of explicitUrlCandidates) {
1131
+ const normalized = normalizeLinkedInCompanyHandle(candidate);
1132
+ if (normalized) {
1133
+ return normalizeLinkedInCompanyPage(normalized);
1134
+ }
1135
+ }
1136
+ const entityUrn = typeof element.entityUrn === "string" ? element.entityUrn : "";
1137
+ const idMatch = entityUrn.match(/\(([^,]+),/);
1138
+ const companyId = idMatch?.[1]?.trim() ?? "";
1139
+ if (/^\d+$/.test(companyId)) {
1140
+ return normalizeLinkedInCompanyPage(companyId);
1141
+ }
1142
+ return null;
1143
+ }
1144
+ function extractLinkedInCompanyNameFromSalesApiElement(element) {
1145
+ if (!element) {
1146
+ return null;
1147
+ }
1148
+ const directCandidates = [
1149
+ typeof element.companyName === "string" ? element.companyName : null,
1150
+ typeof element.name === "string" ? element.name : null,
1151
+ typeof element.title === "string" ? element.title : null
1152
+ ].filter(Boolean);
1153
+ for (const candidate of directCandidates) {
1154
+ const normalized = normalizeLookupWhitespace(candidate);
1155
+ if (normalized) {
1156
+ return normalized;
1157
+ }
1158
+ }
1159
+ const nestedTitle = element.title;
1160
+ if (nestedTitle && typeof nestedTitle === "object" && "text" in nestedTitle) {
1161
+ const text = typeof nestedTitle.text === "string" ? normalizeLookupWhitespace(nestedTitle.text) : "";
1162
+ if (text) {
1163
+ return text;
1164
+ }
1165
+ }
1166
+ return null;
1167
+ }
1168
+ function extractLinkedInCompanyEmployeeCountFromSalesApiElement(element) {
1169
+ if (!element) {
1170
+ return null;
1171
+ }
1172
+ const numericCandidates = [
1173
+ typeof element.employeeCount === "number" ? element.employeeCount : null,
1174
+ typeof element.employeesOnLinkedInCount === "number" ? element.employeesOnLinkedInCount : null
1175
+ ].filter((value) => Number.isFinite(value));
1176
+ if (numericCandidates.length > 0) {
1177
+ return Math.max(0, Math.trunc(numericCandidates[0] ?? 0));
1178
+ }
1179
+ for (const value of collectNestedStrings(element)) {
1180
+ const match = value.match(/(\d[\d.,]*)\s+employees\b/i);
1181
+ if (match) {
1182
+ return Number(match[1].replace(/[.,]/g, ""));
1183
+ }
1184
+ }
1185
+ return null;
1186
+ }
1187
+ function buildLinkedInCompanyLookupVariants(params) {
1188
+ const variants = [];
1189
+ const seen = new Set();
1190
+ const rawCandidates = [
1191
+ normalizeLookupWhitespace(params.companyName),
1192
+ normalizeLookupWhitespace(params.companyNameOriginal)
1193
+ ].filter(Boolean);
1194
+ const normalizedCandidates = rawCandidates.flatMap((candidate) => {
1195
+ const aggressive = aggressivelyCleanLookupCompanyName(candidate);
1196
+ const searchable = normalizeLookupCompanyForSearch(candidate);
1197
+ const dePunctuated = normalizeLookupWhitespace(candidate.replace(/[^\p{L}\p{N}]+/gu, " "));
1198
+ return [candidate, aggressive, searchable, dePunctuated];
1199
+ });
1200
+ for (const companyName of normalizedCandidates) {
1201
+ if (!companyName) {
1202
+ continue;
1203
+ }
1204
+ const key = companyName.toLowerCase();
1205
+ if (seen.has(key)) {
1206
+ continue;
1207
+ }
1208
+ seen.add(key);
1209
+ variants.push({
1210
+ contact_id: params.contactId,
1211
+ companyName
1212
+ });
1213
+ }
1214
+ return variants;
1215
+ }
692
1216
  async function invokeLinkedInUrlEnrichmentDirect(params) {
693
- const config = readLinkedInDirectLookupConfig();
1217
+ const config = await readLinkedInDirectLookupConfig();
694
1218
  const groupedContacts = new Map();
695
1219
  for (const contact of params.contacts) {
696
1220
  const key = contact.email?.trim().toLowerCase() || `contact:${contact.contact_id}`;
@@ -722,23 +1246,119 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
722
1246
  let matchedUrl = null;
723
1247
  let lastError = null;
724
1248
  for (const candidate of variations) {
1249
+ for (const searchVariant of buildLinkedInLookupSearchVariants(candidate)) {
1250
+ const controller = new AbortController();
1251
+ const timeout = setTimeout(controller.abort.bind(controller), Math.min(params.timeoutMs, 20_000));
1252
+ try {
1253
+ const response = await fetch(buildLinkedInSalesApiUrl(searchVariant), {
1254
+ method: "GET",
1255
+ signal: controller.signal,
1256
+ headers: {
1257
+ accept: "*/*",
1258
+ "accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
1259
+ "csrf-token": config.csrfToken,
1260
+ referer: "https://www.linkedin.com/sales/search/people",
1261
+ "sec-fetch-dest": "empty",
1262
+ "sec-fetch-mode": "cors",
1263
+ "sec-fetch-site": "same-origin",
1264
+ "user-agent": config.userAgent,
1265
+ "x-li-identity": config.identity,
1266
+ "x-li-lang": "en_US",
1267
+ "x-restli-protocol-version": "2.0.0",
1268
+ cookie: config.cookie
1269
+ }
1270
+ });
1271
+ if (response.status === 429) {
1272
+ rateLimited = true;
1273
+ lastError = "LinkedIn rate limit";
1274
+ break;
1275
+ }
1276
+ if (!response.ok) {
1277
+ lastError = `LinkedIn returned ${response.status}`;
1278
+ continue;
1279
+ }
1280
+ const data = (await response.json());
1281
+ const profilesFound = data.paging?.total ?? 0;
1282
+ if (profilesFound > 0) {
1283
+ matchedUrl = extractLinkedInProfileUrlFromSalesApiElement(data.elements?.[0]) ?? null;
1284
+ if (matchedUrl) {
1285
+ break;
1286
+ }
1287
+ }
1288
+ }
1289
+ catch (error) {
1290
+ lastError = error instanceof Error ? error.message : "Unknown direct lookup error";
1291
+ }
1292
+ finally {
1293
+ clearTimeout(timeout);
1294
+ }
1295
+ if (matchedUrl || rateLimited) {
1296
+ break;
1297
+ }
1298
+ }
1299
+ if (matchedUrl || rateLimited) {
1300
+ break;
1301
+ }
1302
+ }
1303
+ results.push({
1304
+ contact_id: primary.contact_id,
1305
+ linkedin_url: matchedUrl,
1306
+ error: matchedUrl ? null : lastError
1307
+ });
1308
+ }
1309
+ return {
1310
+ success: true,
1311
+ contacts: results
1312
+ };
1313
+ }
1314
+ async function invokeLinkedInCompanyEnrichmentDirect(params) {
1315
+ const config = await readLinkedInDirectLookupConfig();
1316
+ const primaryContacts = new Map();
1317
+ for (const contact of params.contacts) {
1318
+ const existing = primaryContacts.get(contact.contact_id);
1319
+ if (!existing || existing.isVariation) {
1320
+ primaryContacts.set(contact.contact_id, contact);
1321
+ }
1322
+ }
1323
+ const results = [];
1324
+ let rateLimited = false;
1325
+ for (const contact of primaryContacts.values()) {
1326
+ if (rateLimited) {
1327
+ results.push({
1328
+ contact_id: contact.contact_id,
1329
+ linkedin_company_url: null,
1330
+ error: "LinkedIn rate limit"
1331
+ });
1332
+ continue;
1333
+ }
1334
+ const variants = buildLinkedInCompanyLookupVariants({
1335
+ contactId: contact.contact_id,
1336
+ companyName: contact.companyName,
1337
+ companyNameOriginal: contact.companyNameOriginal
1338
+ });
1339
+ let matchedCompanyUrl = null;
1340
+ let matchedCompanyName = null;
1341
+ let matchedCompanyEmployeeCount = null;
1342
+ let lastError = null;
1343
+ for (const variant of variants) {
725
1344
  const controller = new AbortController();
726
1345
  const timeout = setTimeout(controller.abort.bind(controller), Math.min(params.timeoutMs, 20_000));
727
1346
  try {
728
- const response = await fetch(buildLinkedInSalesApiUrl(candidate.firstName, candidate.lastName, candidate.companyName), {
1347
+ const response = await fetch(buildLinkedInAccountSearchApiUrl(variant.companyName), {
729
1348
  method: "GET",
730
1349
  signal: controller.signal,
731
1350
  headers: {
732
1351
  accept: "*/*",
733
1352
  "accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
734
1353
  "csrf-token": config.csrfToken,
735
- referer: "https://www.linkedin.com/sales/search/people",
1354
+ referer: "https://www.linkedin.com/sales/search/company",
736
1355
  "sec-fetch-dest": "empty",
737
1356
  "sec-fetch-mode": "cors",
738
1357
  "sec-fetch-site": "same-origin",
739
1358
  "user-agent": config.userAgent,
740
1359
  "x-li-identity": config.identity,
741
1360
  "x-li-lang": "en_US",
1361
+ "x-li-page-instance": "urn:li:page:d_sales2_search_accounts;13Jvve6kRGCao+iP0wwAag==",
742
1362
  "x-restli-protocol-version": "2.0.0",
743
1363
  cookie: config.cookie
744
1364
  }
@@ -749,29 +1369,35 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
749
1369
  break;
750
1370
  }
751
1371
  if (!response.ok) {
752
- lastError = `LinkedIn returned ${response.status}`;
1372
+ lastError = `LinkedIn company search returned ${response.status}`;
753
1373
  continue;
754
1374
  }
755
1375
  const data = (await response.json());
756
- const profilesFound = data.paging?.total ?? 0;
757
- if (profilesFound > 0) {
758
- matchedUrl = extractLinkedInProfileUrlFromSalesApiElement(data.elements?.[0]) ?? null;
759
- if (matchedUrl) {
760
- break;
761
- }
1376
+ const first = data.elements?.[0];
1377
+ const companyUrl = extractLinkedInCompanyUrlFromSalesApiElement(first);
1378
+ if (companyUrl) {
1379
+ matchedCompanyUrl = companyUrl;
1380
+ matchedCompanyName = extractLinkedInCompanyNameFromSalesApiElement(first);
1381
+ matchedCompanyEmployeeCount = extractLinkedInCompanyEmployeeCountFromSalesApiElement(first);
1382
+ break;
762
1383
  }
763
1384
  }
764
1385
  catch (error) {
765
- lastError = error instanceof Error ? error.message : "Unknown direct lookup error";
1386
+ lastError = error instanceof Error ? error.message : "Unknown direct company lookup error";
766
1387
  }
767
1388
  finally {
768
1389
  clearTimeout(timeout);
769
1390
  }
1391
+ if (matchedCompanyUrl || rateLimited) {
1392
+ break;
1393
+ }
770
1394
  }
771
1395
  results.push({
772
- contact_id: primary.contact_id,
773
- linkedin_url: matchedUrl,
774
- error: matchedUrl ? null : lastError
1396
+ contact_id: contact.contact_id,
1397
+ linkedin_company_url: matchedCompanyUrl,
1398
+ matched_company_name: matchedCompanyName,
1399
+ matched_company_employee_count: matchedCompanyEmployeeCount,
1400
+ error: matchedCompanyUrl ? null : lastError
775
1401
  });
776
1402
  }
777
1403
  return {
@@ -869,13 +1495,15 @@ async function fetchSalesNavLookupCandidates(params) {
869
1495
  fullName: row.full_name == null ? null : String(row.full_name),
870
1496
  companyName: row.company_name == null ? null : String(row.company_name),
871
1497
  title: row.title == null ? null : String(row.title),
1498
+ companyUrl: row.company_url == null ? null : String(row.company_url),
1499
+ regularCompanyUrl: row.regular_company_url == null ? null : String(row.regular_company_url),
872
1500
  salesNavProfileUrl: row.sales_nav_profile_url == null ? null : String(row.sales_nav_profile_url),
873
1501
  linkedInProfileUrl: row.linkedin_profile_url == null ? null : String(row.linkedin_profile_url)
874
1502
  }));
875
1503
  const fetchRows = async (operator, value) => {
876
1504
  let query = supabase
877
1505
  .from("linkedin_sales_nav_people")
878
- .select("org_id,full_name,company_name,title,sales_nav_profile_url,linkedin_profile_url")
1506
+ .select("org_id,full_name,company_name,title,company_url,regular_company_url,sales_nav_profile_url,linkedin_profile_url")
879
1507
  .limit(10);
880
1508
  if (params.orgId?.trim()) {
881
1509
  query = query.eq("org_id", params.orgId.trim());
@@ -932,18 +1560,31 @@ async function resolveLinkedInUrlsFromSalesNavRows(params) {
932
1560
  });
933
1561
  const best = ranked[0]?.candidate;
934
1562
  const linkedinUrl = best?.linkedInProfileUrl ?? best?.salesNavProfileUrl ?? null;
1563
+ const linkedinCompanyUrl = (() => {
1564
+ const handle = normalizeLinkedInCompanyHandle(best?.regularCompanyUrl ?? "") ??
1565
+ normalizeLinkedInCompanyHandle(best?.companyUrl ?? "");
1566
+ if (handle) {
1567
+ return normalizeLinkedInCompanyPage(handle);
1568
+ }
1569
+ const numericCompanyUrl = typeof best?.companyUrl === "string" ? best.companyUrl.trim() : "";
1570
+ return numericCompanyUrl.length > 0 ? numericCompanyUrl : null;
1571
+ })();
935
1572
  results.push({
936
1573
  clientId: row.clientId,
937
1574
  fullName: row.fullName,
938
1575
  companyName: row.companyName,
939
1576
  linkedinUrl,
1577
+ linkedinCompanyUrl,
940
1578
  found: Boolean(linkedinUrl),
1579
+ companyFound: Boolean(linkedinCompanyUrl),
941
1580
  contactId: String(index + 1),
942
1581
  source: linkedinUrl ? "salesnav-supabase" : null,
1582
+ companySource: linkedinCompanyUrl ? "salesnav-supabase" : null,
943
1583
  matchedFullName: best?.fullName ?? null,
944
1584
  matchedCompanyName: best?.companyName ?? null,
945
1585
  matchedTitle: best?.title ?? null,
946
- matchedOrgId: best?.orgId ?? null
1586
+ matchedOrgId: best?.orgId ?? null,
1587
+ matchedCompanyEmployeeCount: null
947
1588
  });
948
1589
  }
949
1590
  return results;
@@ -1021,6 +1662,199 @@ function normalizeLinkedInCompanyHandle(value) {
1021
1662
  function normalizeLinkedInCompanyPage(handle) {
1022
1663
  return `https://www.linkedin.com/company/${handle}`;
1023
1664
  }
1665
+ function rewriteLinkedInUrlForConfiguredBase(url, env = process.env) {
1666
+ const overrideBase = env.SALESPROMPTER_LINKEDIN_BASE_URL?.trim();
1667
+ if (!overrideBase) {
1668
+ return url;
1669
+ }
1670
+ try {
1671
+ const source = new URL(url);
1672
+ if (!/(^|\.)linkedin\.com$/i.test(source.hostname)) {
1673
+ return url;
1674
+ }
1675
+ const targetBase = new URL(overrideBase);
1676
+ targetBase.pathname = source.pathname;
1677
+ targetBase.search = source.search;
1678
+ targetBase.hash = source.hash;
1679
+ return targetBase.toString();
1680
+ }
1681
+ catch {
1682
+ return url;
1683
+ }
1684
+ }
1685
+ function extractDirectLookupNameParts(contact) {
1686
+ return {
1687
+ firstName: normalizeLookupWhitespace(contact.firstName),
1688
+ lastName: normalizeLookupWhitespace(contact.lastName)
1689
+ };
1690
+ }
1691
+ async function resolveDomainFromLinkedInCompanyUrl(params) {
1692
+ const config = await readLinkedInDirectLookupConfig();
1693
+ const targetUrl = rewriteLinkedInUrlForConfiguredBase(params.companyUrl);
1694
+ const controller = new AbortController();
1695
+ const timeout = setTimeout(() => controller.abort(), Math.min(params.timeoutMs, 20_000));
1696
+ try {
1697
+ const response = await fetch(targetUrl, {
1698
+ method: "GET",
1699
+ signal: controller.signal,
1700
+ headers: {
1701
+ accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1702
+ "accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
1703
+ "user-agent": config.userAgent,
1704
+ cookie: config.cookie
1705
+ }
1706
+ });
1707
+ if (!response.ok) {
1708
+ return null;
1709
+ }
1710
+ const html = await response.text();
1711
+ const parsed = parseLinkedInCompanyPage(html, params.companyUrl);
1712
+ return parsed.domain ?? null;
1713
+ }
1714
+ catch (error) {
1715
+ if (error.name === "AbortError") {
1716
+ throw new Error(`LinkedIn company page lookup timed out after ${params.timeoutMs}ms.`);
1717
+ }
1718
+ return null;
1719
+ }
1720
+ finally {
1721
+ clearTimeout(timeout);
1722
+ }
1723
+ }
1724
+ async function buildDirectEmailEnrichmentBatch(params) {
1725
+ const parsedInput = directEmailEnrichmentInputRowArraySchema.parse(parseDirectEmailEnrichmentInput(params.rawInput));
1726
+ if (parsedInput.length === 0) {
1727
+ throw new Error("No contact rows found. Provide TSV/CSV/JSON input via --in.");
1728
+ }
1729
+ const clientId = resolveDirectEmailEnrichmentClientId(parsedInput, params.clientIdOption);
1730
+ const lookupRows = parsedInput.map((row) => ({
1731
+ clientId: String(clientId),
1732
+ fullName: row.fullName,
1733
+ companyName: row.companyName
1734
+ }));
1735
+ const cleanedCompanyMap = await buildCompanyNameCleaningMap(lookupRows, params.companyCleaningMode);
1736
+ const contacts = toLinkedInUrlLookupContacts(lookupRows, cleanedCompanyMap);
1737
+ let companyEnrichmentByName = new Map();
1738
+ if (!shouldBypassAuth()) {
1739
+ const session = await requireAuthSession().catch(() => null);
1740
+ if (session) {
1741
+ const uniqueCompanies = Array.from(new Map(contacts
1742
+ .filter((contact) => !contact.isVariation)
1743
+ .map((contact) => {
1744
+ const cleanedName = cleanedCompanyMap.get(normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName)) ??
1745
+ normalizeLookupWhitespace(contact.companyNameOriginal ?? contact.companyName);
1746
+ return [
1747
+ normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName),
1748
+ {
1749
+ companyId: contact.contact_id,
1750
+ companyName: contact.companyNameOriginal ?? contact.companyName,
1751
+ companyNameCleaned: cleanedName || null
1752
+ }
1753
+ ];
1754
+ })).values());
1755
+ if (uniqueCompanies.length > 0) {
1756
+ const enrichedCompanies = await enrichDirectEmailCompaniesViaApp(session, {
1757
+ clientId,
1758
+ companies: uniqueCompanies
1759
+ });
1760
+ companyEnrichmentByName = new Map(enrichedCompanies.companies.map((company) => [
1761
+ normalizeLookupCompanyForCleaning(company.companyName),
1762
+ {
1763
+ domain: company.domain ?? null,
1764
+ linkedinCompanyPage: company.linkedinCompanyPage ?? null
1765
+ }
1766
+ ]));
1767
+ }
1768
+ }
1769
+ }
1770
+ const [profileResult, companyResult] = await Promise.all([
1771
+ invokeLinkedInUrlEnrichmentDirect({
1772
+ contacts,
1773
+ timeoutMs: params.timeoutMs
1774
+ }),
1775
+ companyEnrichmentByName.size > 0
1776
+ ? Promise.resolve({
1777
+ success: true,
1778
+ contacts: contacts
1779
+ .filter((contact) => !contact.isVariation)
1780
+ .map((contact) => ({
1781
+ contact_id: contact.contact_id,
1782
+ linkedin_company_url: companyEnrichmentByName.get(normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName))?.linkedinCompanyPage ?? null,
1783
+ matched_company_name: null,
1784
+ matched_company_employee_count: null,
1785
+ error: null
1786
+ }))
1787
+ })
1788
+ : invokeLinkedInCompanyEnrichmentDirect({
1789
+ contacts,
1790
+ timeoutMs: params.timeoutMs
1791
+ })
1792
+ ]);
1793
+ const profileByContactId = new Map(profileResult.contacts.map((row) => [row.contact_id, row.linkedin_url ?? null]));
1794
+ const companyByContactId = new Map(companyResult.contacts.map((row) => [row.contact_id, row.linkedin_company_url ?? null]));
1795
+ const reportRows = [];
1796
+ const queueRows = [];
1797
+ for (let index = 0; index < contacts.length; index += 1) {
1798
+ const contact = contacts[index];
1799
+ if (contact.isVariation) {
1800
+ continue;
1801
+ }
1802
+ const names = extractDirectLookupNameParts(contact);
1803
+ const appCompanyEnrichment = companyEnrichmentByName.get(normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName)) ?? { domain: null, linkedinCompanyPage: null };
1804
+ const linkedinCompanyUrl = appCompanyEnrichment.linkedinCompanyPage ??
1805
+ companyByContactId.get(contact.contact_id) ??
1806
+ null;
1807
+ const domain = appCompanyEnrichment.domain ??
1808
+ (linkedinCompanyUrl
1809
+ ? await resolveDomainFromLinkedInCompanyUrl({
1810
+ companyUrl: linkedinCompanyUrl,
1811
+ timeoutMs: params.timeoutMs
1812
+ })
1813
+ : null);
1814
+ let skippedReason = null;
1815
+ if (!names.firstName || !names.lastName) {
1816
+ skippedReason = "missing-name";
1817
+ }
1818
+ else if (!domain) {
1819
+ skippedReason = "missing-domain";
1820
+ }
1821
+ const companyHandle = linkedinCompanyUrl ? normalizeLinkedInCompanyHandle(linkedinCompanyUrl) : null;
1822
+ const numericCompanyId = companyHandle && /^\d+$/.test(companyHandle) ? companyHandle : String(index + 1);
1823
+ const reportRow = {
1824
+ contactId: contact.contact_id,
1825
+ clientId: String(clientId),
1826
+ companyId: numericCompanyId,
1827
+ companyName: contact.companyNameOriginal ?? contact.companyName,
1828
+ fullName: normalizeLookupWhitespace(`${names.firstName} ${names.lastName}`),
1829
+ firstName: names.firstName,
1830
+ lastName: names.lastName,
1831
+ linkedinUrl: profileByContactId.get(contact.contact_id) ?? null,
1832
+ linkedinCompanyUrl,
1833
+ domain,
1834
+ ready: skippedReason == null,
1835
+ skippedReason
1836
+ };
1837
+ reportRows.push(reportRow);
1838
+ if (!reportRow.ready || !domain) {
1839
+ continue;
1840
+ }
1841
+ queueRows.push({
1842
+ clientId: String(clientId),
1843
+ contactId: contact.contact_id,
1844
+ companyId: numericCompanyId,
1845
+ firstName_cleaned: names.firstName,
1846
+ lastName_cleaned: names.lastName,
1847
+ domain,
1848
+ companyScore: null,
1849
+ seniorityId: null
1850
+ });
1851
+ }
1852
+ return {
1853
+ clientId,
1854
+ queueRows,
1855
+ reportRows
1856
+ };
1857
+ }
1024
1858
  function titleCaseSlug(value) {
1025
1859
  return value
1026
1860
  .split(/[-_]/)
@@ -2105,6 +2939,17 @@ async function launchLinkedInCompaniesBackfill(session, payload) {
2105
2939
  }), LinkedInCompanyBackfillLaunchResponseSchema);
2106
2940
  return value;
2107
2941
  }
2942
+ async function enrichDirectEmailCompaniesViaApp(session, payload) {
2943
+ const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/email-enrichment/companies`, {
2944
+ method: "POST",
2945
+ headers: {
2946
+ "Content-Type": "application/json",
2947
+ Authorization: `Bearer ${currentSession.accessToken}`
2948
+ },
2949
+ body: JSON.stringify(payload)
2950
+ }), CliEmailEnrichmentCompaniesResponseSchema);
2951
+ return value;
2952
+ }
2108
2953
  async function fetchLinkedInCompaniesBackfillStatus(session, payload) {
2109
2954
  const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/linkedin-companies/status?clientId=${encodeURIComponent(String(payload.clientId))}&containerId=${encodeURIComponent(payload.containerId)}`, {
2110
2955
  method: "GET",
@@ -3667,6 +4512,7 @@ program.configureHelp({
3667
4512
  });
3668
4513
  program.addHelpText("after", `
3669
4514
  LLM operator tips:
4515
+ - New here? Create your account at https://salesprompter.ai/sign-up, then run: salesprompter auth:login
3670
4516
  - Prefer non-interactive auth: set SALESPROMPTER_TOKEN (+ optional SALESPROMPTER_API_BASE_URL).
3671
4517
  - Use machine output for tools: add --json.
3672
4518
  - One-shot leads flow: leads:pipeline --icp <path> --domain <company.com> --count <n>.
@@ -3773,6 +4619,7 @@ program
3773
4619
  .option("--org-id <id>", "Optional Sales Nav workspace org id for the lookup-first pass.")
3774
4620
  .option("--external-user-id <id>", "Deprecated compatibility option. Ignored by the direct CLI lookup.")
3775
4621
  .option("--timeout-ms <number>", "Lookup timeout in milliseconds", "30000")
4622
+ .option("--company-cleaning <mode>", "Company cleaning mode: off, basic, or ai", "basic")
3776
4623
  .option("--dry-run", "Preview the normalized payload without calling LinkedIn", false)
3777
4624
  .action(async (options) => {
3778
4625
  const timeoutMs = z.coerce.number().int().min(1000).max(300000).parse(options.timeoutMs);
@@ -3791,12 +4638,15 @@ program
3791
4638
  sessionOrgId = "";
3792
4639
  }
3793
4640
  }
3794
- const contacts = toLinkedInUrlLookupContacts(rows);
4641
+ const companyCleaningMode = resolveCompanyCleaningMode(String(options.companyCleaning ?? process.env.SALESPROMPTER_COMPANY_CLEANING_MODE ?? "basic"));
4642
+ const cleanedCompanyMap = await buildCompanyNameCleaningMap(rows, companyCleaningMode);
4643
+ const contacts = toLinkedInUrlLookupContacts(rows, cleanedCompanyMap);
3795
4644
  if (options.dryRun) {
3796
4645
  const payload = {
3797
4646
  status: "ok",
3798
4647
  dryRun: true,
3799
4648
  orgId: String(options.orgId ?? "").trim() || null,
4649
+ companyCleaningMode,
3800
4650
  contacts: contacts.length,
3801
4651
  sample: contacts.slice(0, 5)
3802
4652
  };
@@ -3831,11 +4681,41 @@ program
3831
4681
  }
3832
4682
  }
3833
4683
  }
4684
+ try {
4685
+ const companyResult = await invokeLinkedInCompanyEnrichmentDirect({
4686
+ contacts,
4687
+ timeoutMs
4688
+ });
4689
+ const companyByContactId = new Map(companyResult.contacts.map((contact) => [
4690
+ contact.contact_id,
4691
+ {
4692
+ linkedinCompanyUrl: contact.linkedin_company_url ?? null,
4693
+ matchedCompanyName: contact.matched_company_name ?? null,
4694
+ matchedCompanyEmployeeCount: contact.matched_company_employee_count ?? null
4695
+ }
4696
+ ]));
4697
+ for (const row of enrichedRows) {
4698
+ const company = companyByContactId.get(row.contactId);
4699
+ if (!company || row.linkedinCompanyUrl) {
4700
+ continue;
4701
+ }
4702
+ row.linkedinCompanyUrl = company.linkedinCompanyUrl;
4703
+ row.companyFound = Boolean(company.linkedinCompanyUrl);
4704
+ row.companySource = company.linkedinCompanyUrl ? "linkedin-direct" : row.companySource ?? null;
4705
+ row.matchedCompanyName = company.matchedCompanyName ?? row.matchedCompanyName ?? null;
4706
+ row.matchedCompanyEmployeeCount =
4707
+ company.matchedCompanyEmployeeCount ?? row.matchedCompanyEmployeeCount ?? null;
4708
+ }
4709
+ }
4710
+ catch (error) {
4711
+ writeProgress(`Skipping separate company enrichment: ${error instanceof Error ? error.message : String(error)}`);
4712
+ }
3834
4713
  const payload = {
3835
4714
  status: "ok",
3836
4715
  orgId: String(options.orgId ?? "").trim() || null,
3837
4716
  requested: rows.length,
3838
4717
  found: enrichedRows.filter((row) => row.found).length,
4718
+ companiesFound: enrichedRows.filter((row) => row.companyFound).length,
3839
4719
  directAttempted,
3840
4720
  rows: enrichedRows
3841
4721
  };
@@ -5818,6 +6698,123 @@ program
5818
6698
  execution
5819
6699
  });
5820
6700
  });
6701
+ program
6702
+ .command("contacts:process-emails")
6703
+ .alias("contacts:resolve-emails")
6704
+ .alias("hunter-emailfinder:run:bq")
6705
+ .description("Process the next batch of contact email enrichment for a workspace.")
6706
+ .option("--client-id <number>", "Queue clientId to process")
6707
+ .option("--in <path>", "Optional TSV/CSV/JSON input path with company and contact rows")
6708
+ .option("--limit <number>", "Max queue rows to fetch", "500")
6709
+ .requiredOption("--out-dir <path>", "Output directory for artifacts")
6710
+ .option("--trace-id <traceId>", "Trace id for this batch")
6711
+ .option("--endpoint-url <url>", "Override the enrichment workflow endpoint")
6712
+ .option("--timeout-ms <number>", "Workflow trigger timeout in milliseconds", "30000")
6713
+ .option("--company-cleaning <mode>", "Company cleaning mode for direct input: off, basic, or ai", "basic")
6714
+ .option("--dry-run", "Preview the next batch without starting background processing", false)
6715
+ .action(async (options) => {
6716
+ const limit = z.coerce.number().int().min(1).max(50000).parse(options.limit);
6717
+ const outDir = z.string().min(1).parse(options.outDir);
6718
+ const timeoutMs = z.coerce.number().int().min(1000).max(300000).parse(options.timeoutMs);
6719
+ const usingDirectInput = typeof options.in === "string" && options.in.trim().length > 0;
6720
+ const companyCleaningMode = resolveCompanyCleaningMode(String(options.companyCleaning ?? process.env.SALESPROMPTER_COMPANY_CLEANING_MODE ?? "basic"));
6721
+ let clientId;
6722
+ let queueRows;
6723
+ let directReportRows = null;
6724
+ let queueSqlPath = null;
6725
+ let directReportPath = null;
6726
+ if (usingDirectInput) {
6727
+ const rawInput = await readFile(options.in, "utf8");
6728
+ const directBatch = await buildDirectEmailEnrichmentBatch({
6729
+ rawInput,
6730
+ clientIdOption: options.clientId,
6731
+ timeoutMs,
6732
+ companyCleaningMode
6733
+ });
6734
+ clientId = directBatch.clientId;
6735
+ queueRows = directBatch.queueRows.slice(0, limit);
6736
+ directReportRows = directBatch.reportRows;
6737
+ }
6738
+ else {
6739
+ clientId = z.coerce.number().int().positive().parse(options.clientId);
6740
+ queueSqlPath = `${outDir}/email-enrichment-input-${clientId}.sql`;
6741
+ const queueSql = buildHunterEmailfinderQueueSql(clientId, limit);
6742
+ await writeTextFile(queueSqlPath, `${queueSql}\n`);
6743
+ const rawQueueRows = await runBigQueryRows(queueSql, { maxRows: limit });
6744
+ queueRows = hunterEmailfinderQueueRowArraySchema.parse(rawQueueRows);
6745
+ }
6746
+ const traceId = z.string().min(1).parse(options.traceId ?? `salesprompter-cli-email-${clientId}-${Date.now()}`);
6747
+ const queueRowsPath = `${outDir}/email-enrichment-input-${clientId}.json`;
6748
+ const requestPath = `${outDir}/email-enrichment-request-${clientId}.json`;
6749
+ const responsePath = `${outDir}/email-enrichment-response-${clientId}.json`;
6750
+ await writeJsonFile(queueRowsPath, queueRows);
6751
+ if (directReportRows) {
6752
+ directReportPath = `${outDir}/email-enrichment-direct-${clientId}.json`;
6753
+ await writeJsonFile(directReportPath, {
6754
+ clientId,
6755
+ companyCleaningMode,
6756
+ rows: directReportRows
6757
+ });
6758
+ }
6759
+ const payload = buildHunterEmailfinderTriggerPayload({
6760
+ traceId,
6761
+ clientId: String(clientId),
6762
+ queueRows
6763
+ });
6764
+ await writeJsonFile(requestPath, {
6765
+ traceId,
6766
+ clientId,
6767
+ queueRows: queueRows.length,
6768
+ payload
6769
+ });
6770
+ let trigger = null;
6771
+ if (!options.dryRun && queueRows.length > 0) {
6772
+ const config = readHunterEmailfinderConfig(process.env, options.endpointUrl);
6773
+ const result = await triggerHunterEmailfinderWorkflow({
6774
+ config,
6775
+ externalUserId: `email-enrichment-client-${clientId}`,
6776
+ timeoutMs,
6777
+ payload
6778
+ });
6779
+ trigger = {
6780
+ triggered: result.response.ok,
6781
+ status: result.response.status
6782
+ };
6783
+ await writeJsonFile(responsePath, {
6784
+ traceId,
6785
+ clientId,
6786
+ status: result.response.status,
6787
+ ok: result.response.ok,
6788
+ endpoint: result.endpoint,
6789
+ body: result.parsedBody
6790
+ });
6791
+ if (!result.response.ok) {
6792
+ throw new Error(`Email enrichment workflow returned ${result.response.status}: ${result.bodyText || "No response body"}`);
6793
+ }
6794
+ }
6795
+ printOutput({
6796
+ status: "ok",
6797
+ clientId,
6798
+ limit,
6799
+ traceId,
6800
+ dryRun: Boolean(options.dryRun),
6801
+ mode: usingDirectInput ? "direct-input" : "workspace-queue",
6802
+ companyCleaningMode: usingDirectInput ? companyCleaningMode : null,
6803
+ contactsRead: directReportRows?.length ?? null,
6804
+ contactsQueued: queueRows.length,
6805
+ started: Boolean(trigger?.triggered),
6806
+ profilesFound: directReportRows ? directReportRows.filter((row) => row.linkedinUrl).length : null,
6807
+ companiesFound: directReportRows ? directReportRows.filter((row) => row.linkedinCompanyUrl).length : null,
6808
+ domainsFound: directReportRows ? directReportRows.filter((row) => row.domain).length : null,
6809
+ artifacts: {
6810
+ inputSqlPath: queueSqlPath,
6811
+ inputRowsPath: queueRowsPath,
6812
+ directReportPath,
6813
+ requestPath,
6814
+ responsePath: trigger ? responsePath : null
6815
+ }
6816
+ });
6817
+ });
5821
6818
  async function main() {
5822
6819
  if (shouldAutoRunWizard(process.argv)) {
5823
6820
  await runWizard();