salesprompter-cli 0.1.25 → 0.1.27

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)),
@@ -236,8 +248,39 @@ const SalesNavigatorCrawlReportResponseSchema = z.object({
236
248
  status: z.literal("ok"),
237
249
  job: SalesNavigatorCrawlJobSummarySchema
238
250
  });
251
+ const cliPacks = [
252
+ {
253
+ slug: "contacts",
254
+ title: "Contacts",
255
+ summary: "Resolve profile URLs and work from pasted contact lists.",
256
+ commands: ["contacts:resolve-profiles", "contacts:resolve-emails"],
257
+ installStatus: "included"
258
+ },
259
+ {
260
+ slug: "research",
261
+ title: "Research",
262
+ summary: "Scrape markets and enrich companies before outreach.",
263
+ commands: ["market:scrape", "companies:enrich"],
264
+ installStatus: "included"
265
+ },
266
+ {
267
+ slug: "discovery",
268
+ title: "Discovery",
269
+ summary: "Find leads from product and market inputs.",
270
+ commands: ["leads:discover", "search:run", "search:status", "search:export", "search:count"],
271
+ installStatus: "included"
272
+ },
273
+ {
274
+ slug: "outreach",
275
+ title: "Outreach",
276
+ summary: "Prepare and sync qualified leads into downstream systems.",
277
+ commands: ["sync:outreach", "sync:crm"],
278
+ installStatus: "included"
279
+ }
280
+ ];
239
281
  const helpAliasByCommandName = new Map([
240
282
  ["contacts:find-linkedin-urls", "contacts:resolve-profiles"],
283
+ ["contacts:process-emails", "contacts:resolve-emails"],
241
284
  ["linkedin-companies:backfill", "companies:enrich"],
242
285
  ["linkedin-products:scrape", "market:scrape"],
243
286
  ["salesnav:from-product-category", "leads:discover"],
@@ -247,11 +290,17 @@ const helpAliasByCommandName = new Map([
247
290
  ["salesnav:count", "search:count"]
248
291
  ]);
249
292
  const helpVisibleCommandNames = new Set([
293
+ "setup",
294
+ "doctor",
295
+ "packs:list",
296
+ "packs:add",
297
+ "upgrade",
250
298
  "auth:login",
251
299
  "wizard",
252
300
  "auth:whoami",
253
301
  "llm:ready",
254
302
  "contacts:find-linkedin-urls",
303
+ "contacts:process-emails",
255
304
  "auth:logout",
256
305
  "account:resolve",
257
306
  "icp:define",
@@ -498,6 +547,296 @@ function normalizeLookupCompanyForSearch(value) {
498
547
  .replace(/\s+/g, " ")
499
548
  .trim();
500
549
  }
550
+ function normalizeLookupCompanyForCleaning(value) {
551
+ return normalizeLookupWhitespace(value)
552
+ .replace(/[+]/g, " ")
553
+ .replace(/\s+/g, " ")
554
+ .trim();
555
+ }
556
+ function stripLookupCompanyLegalSuffixes(value) {
557
+ let cleaned = normalizeLookupCompanyForCleaning(value);
558
+ if (!cleaned) {
559
+ return "";
560
+ }
561
+ cleaned = cleaned.replace(/\s*\([^)]*\)\s*$/g, "").trim();
562
+ const suffixes = [
563
+ "gmbh & co. kgaa",
564
+ "gmbh & co kgaa",
565
+ "gmbh & co. kg",
566
+ "gmbh & co kg",
567
+ "ug (haftungsbeschrankt)",
568
+ "ug (haftungsbeschränkt)",
569
+ "wirtschaftsprufungsgesellschaft",
570
+ "wirtschaftsprüfungsgesellschaft",
571
+ "gesellschaft",
572
+ "corporation",
573
+ "holdings",
574
+ "holding",
575
+ "international",
576
+ "services",
577
+ "service",
578
+ "solutions",
579
+ "systems",
580
+ "consulting",
581
+ "management",
582
+ "technologies",
583
+ "technology",
584
+ "digital",
585
+ "software",
586
+ "global",
587
+ "worldwide",
588
+ "deutschland",
589
+ "germany",
590
+ "america",
591
+ "europe",
592
+ "company",
593
+ "verlag",
594
+ "servtec",
595
+ "group",
596
+ "gruppe",
597
+ "tech",
598
+ "gmbh",
599
+ "kgaa",
600
+ "mbh",
601
+ "ohg",
602
+ "kg",
603
+ "ag",
604
+ "ug",
605
+ "se",
606
+ "sa",
607
+ "s.a.",
608
+ "sarl",
609
+ "s.r.l.",
610
+ "srl",
611
+ "llc",
612
+ "ltd",
613
+ "limited",
614
+ "inc.",
615
+ "inc",
616
+ "corp.",
617
+ "corp",
618
+ "co.",
619
+ "co",
620
+ "plc",
621
+ "bv",
622
+ "b.v.",
623
+ "nv",
624
+ "n.v.",
625
+ "oyj",
626
+ "oy",
627
+ "aps",
628
+ "a/s",
629
+ "ab",
630
+ "as"
631
+ ];
632
+ const escapedSuffixes = suffixes
633
+ .map((suffix) => suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
634
+ .sort((left, right) => right.length - left.length)
635
+ .join("|");
636
+ const separatorPattern = String.raw `(?:\s*[,|/]\s*|\s+[-–]\s+|\s+)`;
637
+ const trailingSuffixPattern = new RegExp(`${separatorPattern}(?:${escapedSuffixes})\\s*$`, "i");
638
+ const suffixBeforeSeparatorPattern = new RegExp(`\\s+(?:${escapedSuffixes})(?=\\s*[-–|/])`, "ig");
639
+ for (let index = 0; index < 6; index += 1) {
640
+ const next = cleaned
641
+ .replace(suffixBeforeSeparatorPattern, "")
642
+ .replace(trailingSuffixPattern, "")
643
+ .trim();
644
+ if (next === cleaned) {
645
+ break;
646
+ }
647
+ cleaned = next;
648
+ }
649
+ return normalizeLookupWhitespace(cleaned);
650
+ }
651
+ function aggressivelyCleanLookupCompanyName(value) {
652
+ let cleaned = stripLookupCompanyLegalSuffixes(value);
653
+ if (!cleaned) {
654
+ return "";
655
+ }
656
+ const suffixesToRemove = [
657
+ "group",
658
+ "gruppe",
659
+ "international",
660
+ "global",
661
+ "worldwide",
662
+ "services",
663
+ "service",
664
+ "servtec",
665
+ "solutions",
666
+ "systems",
667
+ "consulting",
668
+ "management",
669
+ "technologies",
670
+ "technology",
671
+ "digital",
672
+ "tech",
673
+ "it",
674
+ "software",
675
+ "europe",
676
+ "deutschland",
677
+ "germany",
678
+ "usa",
679
+ "uk",
680
+ "america",
681
+ "holdings",
682
+ "holding",
683
+ "corporation",
684
+ "company",
685
+ "verlag",
686
+ "gesellschaft",
687
+ "wirtschaftsprüfungsgesellschaft",
688
+ "wirtschaftsprufungsgesellschaft"
689
+ ];
690
+ for (const suffix of suffixesToRemove) {
691
+ const pattern = new RegExp(`(?:\\s+|\\s*[-–|/]\\s*)${suffix}\\s*$`, "i");
692
+ cleaned = cleaned.replace(pattern, "").trim();
693
+ }
694
+ const segments = cleaned
695
+ .split(/\s*[-–|/]\s*/)
696
+ .map((segment) => normalizeLookupWhitespace(segment))
697
+ .filter(Boolean);
698
+ if (segments.length > 1) {
699
+ 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;
700
+ const trailingSegments = segments.slice(1);
701
+ const shouldCollapseToFirstSegment = trailingSegments.every((segment) => segment
702
+ .split(/\s+/)
703
+ .filter(Boolean)
704
+ .every((word) => descriptorPattern.test(word)));
705
+ if (shouldCollapseToFirstSegment) {
706
+ cleaned = segments[0] ?? cleaned;
707
+ }
708
+ }
709
+ const words = cleaned.split(/\s+/).filter(Boolean);
710
+ if (words.length === 2) {
711
+ const [firstWord, secondWord] = words;
712
+ if (/^(servtec|digital|tech|solutions|systems|services|consulting|management|technologies)$/i.test(secondWord ?? "")) {
713
+ cleaned = firstWord ?? cleaned;
714
+ }
715
+ }
716
+ const descriptorTailPattern = /^(robotic|solutions?|systems?|services?|service|consulting|management|technologies?|technology|digital|tech|software|logistics|distribution|distributions|trading|handel)$/i;
717
+ const cleanedWords = cleaned.split(/\s+/).filter(Boolean);
718
+ if (cleanedWords.length >= 2 &&
719
+ cleanedWords.slice(1).every((word) => descriptorTailPattern.test(word))) {
720
+ cleaned = cleanedWords[0] ?? cleaned;
721
+ }
722
+ return normalizeLookupWhitespace(cleaned);
723
+ }
724
+ function resolveCompanyCleaningMode(value) {
725
+ const normalized = String(value ?? "basic").trim().toLowerCase();
726
+ if (normalized === "off" || normalized === "raw" || normalized === "none") {
727
+ return "off";
728
+ }
729
+ if (normalized === "ai" || normalized === "openai") {
730
+ return "ai";
731
+ }
732
+ return "basic";
733
+ }
734
+ function readOpenAiCompanyCleanerConfig() {
735
+ const apiKey = process.env.SALESPROMPTER_OPENAI_API_KEY?.trim() ||
736
+ process.env.OPENAI_API_KEY?.trim() ||
737
+ "";
738
+ if (!apiKey) {
739
+ throw new Error("Missing OpenAI API key for AI company cleaning. Set OPENAI_API_KEY or SALESPROMPTER_OPENAI_API_KEY.");
740
+ }
741
+ const baseUrl = process.env.SALESPROMPTER_OPENAI_BASE_URL?.trim().replace(/\/+$/, "") || "https://api.openai.com/v1";
742
+ const promptId = process.env.SALESPROMPTER_COMPANY_CLEANER_PROMPT_ID?.trim() ||
743
+ "pmpt_69c164c582a48196b288a8909de87c700694068fbd1b0e55";
744
+ const promptVersion = process.env.SALESPROMPTER_COMPANY_CLEANER_PROMPT_VERSION?.trim() || "2";
745
+ const maxOutputTokens = z.coerce
746
+ .number()
747
+ .int()
748
+ .min(256)
749
+ .max(32768)
750
+ .parse(process.env.SALESPROMPTER_COMPANY_CLEANER_MAX_OUTPUT_TOKENS ?? "4096");
751
+ return {
752
+ apiKey,
753
+ baseUrl,
754
+ promptId,
755
+ promptVersion,
756
+ maxOutputTokens
757
+ };
758
+ }
759
+ async function cleanCompanyNamesWithOpenAi(companyNames, config) {
760
+ if (companyNames.length === 0) {
761
+ return new Map();
762
+ }
763
+ const response = await fetch(`${config.baseUrl}/responses`, {
764
+ method: "POST",
765
+ headers: {
766
+ Authorization: `Bearer ${config.apiKey}`,
767
+ "Content-Type": "application/json"
768
+ },
769
+ body: JSON.stringify({
770
+ prompt: {
771
+ id: config.promptId,
772
+ version: config.promptVersion
773
+ },
774
+ input: [
775
+ {
776
+ role: "user",
777
+ content: JSON.stringify(companyNames)
778
+ }
779
+ ],
780
+ text: {
781
+ format: {
782
+ type: "text"
783
+ }
784
+ },
785
+ reasoning: {},
786
+ max_output_tokens: config.maxOutputTokens,
787
+ store: true
788
+ })
789
+ });
790
+ if (!response.ok) {
791
+ const details = await response.text();
792
+ throw new Error(`OpenAI company cleaning failed with ${response.status}: ${details}`);
793
+ }
794
+ const payload = (await response.json());
795
+ const incomplete = payload.status === "incomplete" || payload.incomplete_details;
796
+ if (incomplete) {
797
+ const reason = payload.incomplete_details?.reason || payload.status || "unknown";
798
+ throw new Error(`OpenAI company cleaning returned an incomplete response (${reason}).`);
799
+ }
800
+ const rawText = payload.output_text ??
801
+ payload.output?.flatMap((item) => item.content ?? []).map((item) => item.text ?? "").join("") ??
802
+ "";
803
+ if (!rawText.trim()) {
804
+ throw new Error("OpenAI company cleaning returned no text.");
805
+ }
806
+ const parsed = z
807
+ .array(z.object({
808
+ companyName_input: z.string(),
809
+ companyName_cleaned: z.string().nullish()
810
+ }))
811
+ .parse(JSON.parse(rawText));
812
+ const cleanedByInput = new Map();
813
+ for (const item of parsed) {
814
+ cleanedByInput.set(normalizeLookupCompanyForCleaning(item.companyName_input), aggressivelyCleanLookupCompanyName(item.companyName_cleaned ?? item.companyName_input));
815
+ }
816
+ return cleanedByInput;
817
+ }
818
+ async function buildCompanyNameCleaningMap(rows, mode) {
819
+ const uniqueCompanyNames = Array.from(new Set(rows
820
+ .map((row) => normalizeLookupCompanyForCleaning(row.companyName))
821
+ .filter((value) => value.length > 0)));
822
+ if (uniqueCompanyNames.length === 0) {
823
+ return new Map();
824
+ }
825
+ const cleanedByInput = new Map();
826
+ for (const companyName of uniqueCompanyNames) {
827
+ cleanedByInput.set(companyName, aggressivelyCleanLookupCompanyName(companyName));
828
+ }
829
+ if (mode !== "ai") {
830
+ return cleanedByInput;
831
+ }
832
+ const aiCleanedByInput = await cleanCompanyNamesWithOpenAi(uniqueCompanyNames, readOpenAiCompanyCleanerConfig());
833
+ for (const [input, cleaned] of aiCleanedByInput.entries()) {
834
+ if (cleaned) {
835
+ cleanedByInput.set(input, cleaned);
836
+ }
837
+ }
838
+ return cleanedByInput;
839
+ }
501
840
  function splitLookupFullName(fullName) {
502
841
  const parts = normalizeLookupWhitespace(fullName).split(" ").filter(Boolean);
503
842
  return {
@@ -581,12 +920,12 @@ function parseLinkedInUrlLookupInput(content) {
581
920
  }))
582
921
  .filter((row) => row.fullName.length > 0 || row.companyName.length > 0);
583
922
  }
584
- function toLinkedInUrlLookupContacts(rows) {
923
+ function toLinkedInUrlLookupContacts(rows, cleanedCompanyMap = new Map()) {
585
924
  return rows.flatMap((row, index) => {
586
925
  const contactId = String(index + 1);
587
926
  const syntheticEmail = row.email?.trim() || buildSyntheticLookupEmail(contactId);
588
927
  const rawCompanyName = normalizeLookupWhitespace(row.companyName);
589
- const cleanedCompanyName = normalizeLookupCompanyForSearch(rawCompanyName);
928
+ const cleanedCompanyName = normalizeLookupCompanyForSearch(cleanedCompanyMap.get(normalizeLookupCompanyForCleaning(rawCompanyName)) ?? rawCompanyName);
590
929
  const rawFullName = normalizeLookupWhitespace(row.fullName);
591
930
  if (looksLikeLookupCompanyRow(rawFullName, rawCompanyName)) {
592
931
  return [
@@ -652,45 +991,265 @@ function deriveCsrfTokenFromCookie(cookie) {
652
991
  const match = cookie.match(/JSESSIONID="?([^";]+)"?/i);
653
992
  return match?.[1]?.trim() || "";
654
993
  }
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
- "";
994
+ function readLinkedInDirectLookupEnvConfig() {
664
995
  const cookie = process.env.SALESPROMPTER_LINKEDIN_SALES_NAV_COOKIE?.trim() ||
665
996
  process.env.LINKEDIN_SALES_NAV_COOKIE?.trim() ||
666
997
  "";
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";
998
+ const identity = process.env.SALESPROMPTER_LINKEDIN_X_LI_IDENTITY?.trim() ||
999
+ process.env.LINKEDIN_X_LI_IDENTITY?.trim() ||
1000
+ "";
1001
+ const csrfToken = process.env.SALESPROMPTER_LINKEDIN_CSRF_TOKEN?.trim() ||
1002
+ process.env.LINKEDIN_CSRF_TOKEN?.trim() ||
1003
+ deriveCsrfTokenFromCookie(cookie);
669
1004
  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.");
1005
+ return null;
671
1006
  }
672
1007
  return {
673
1008
  csrfToken,
674
1009
  identity,
675
1010
  cookie,
1011
+ userAgent: process.env.SALESPROMPTER_LINKEDIN_USER_AGENT?.trim() ||
1012
+ "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"
1013
+ };
1014
+ }
1015
+ function extractStoredLinkedInLookupValue(metadata, key) {
1016
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
1017
+ return "";
1018
+ }
1019
+ const record = metadata;
1020
+ const linkedInSession = record.linkedInSession && typeof record.linkedInSession === "object" && !Array.isArray(record.linkedInSession)
1021
+ ? record.linkedInSession
1022
+ : null;
1023
+ const value = linkedInSession?.[key];
1024
+ return typeof value === "string" ? value.trim() : "";
1025
+ }
1026
+ async function readStoredLinkedInDirectLookupConfig() {
1027
+ const supabase = createLinkedInSessionSupabaseClient();
1028
+ if (!supabase) {
1029
+ return null;
1030
+ }
1031
+ const claimed = await claimValidatedSalesNavigatorSessionCookieForCli({
1032
+ queryUrl: "https://www.linkedin.com/sales/search/people",
1033
+ source: "cli_direct_lookup",
1034
+ supabase
1035
+ });
1036
+ if (!claimed) {
1037
+ return null;
1038
+ }
1039
+ const lookupResult = await supabase
1040
+ .from("linkedin_session_cookies")
1041
+ .select("metadata")
1042
+ .eq("session_cookie_sha256", claimed.sessionCookieSha256)
1043
+ .maybeSingle();
1044
+ if (lookupResult.error && !/linkedin_session_cookies/.test(lookupResult.error.message)) {
1045
+ throw new Error(`Failed to read stored LinkedIn session metadata: ${lookupResult.error.message}`);
1046
+ }
1047
+ const metadata = lookupResult.data?.metadata;
1048
+ const identity = extractStoredLinkedInLookupValue(metadata, "linkedInIdentity");
1049
+ const csrfToken = extractStoredLinkedInLookupValue(metadata, "csrfToken") ||
1050
+ deriveCsrfTokenFromCookie(claimed.sessionCookie);
1051
+ const userAgent = process.env.SALESPROMPTER_LINKEDIN_USER_AGENT?.trim() ||
1052
+ extractStoredLinkedInLookupValue(metadata, "userAgent") ||
1053
+ "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";
1054
+ if (!identity || !csrfToken) {
1055
+ return null;
1056
+ }
1057
+ return {
1058
+ csrfToken,
1059
+ identity,
1060
+ cookie: claimed.sessionCookie,
676
1061
  userAgent
677
1062
  };
678
1063
  }
1064
+ let cachedLinkedInDirectLookupConfig = null;
1065
+ async function readLinkedInDirectLookupConfig() {
1066
+ if (cachedLinkedInDirectLookupConfig) {
1067
+ return cachedLinkedInDirectLookupConfig;
1068
+ }
1069
+ const envConfig = readLinkedInDirectLookupEnvConfig();
1070
+ if (envConfig) {
1071
+ cachedLinkedInDirectLookupConfig = envConfig;
1072
+ return envConfig;
1073
+ }
1074
+ const storedConfig = await readStoredLinkedInDirectLookupConfig();
1075
+ if (storedConfig) {
1076
+ cachedLinkedInDirectLookupConfig = storedConfig;
1077
+ return storedConfig;
1078
+ }
1079
+ 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.");
1080
+ }
679
1081
  function generateLinkedInSessionId() {
680
1082
  return Array.from({ length: 22 }, () => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".charAt(Math.floor(Math.random() * 62))).join("");
681
1083
  }
682
- function buildLinkedInSalesApiUrl(firstName, lastName, companyName) {
1084
+ function buildLinkedInSalesApiUrl(params) {
1085
+ const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
1086
+ "https://www.linkedin.com";
1087
+ const encodedFirstName = encodeURIComponent(params.firstName);
1088
+ const encodedLastName = encodeURIComponent(params.lastName);
1089
+ const encodedCompanyName = encodeURIComponent(params.companyName);
1090
+ const filters = params.searchMode === "current_company"
1091
+ ? `(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)))`
1092
+ : `(type:FIRST_NAME,values:List((text:${encodedFirstName},selectionType:INCLUDED))),(type:LAST_NAME,values:List((text:${encodedLastName},selectionType:INCLUDED)))`;
1093
+ const keywordsSegment = params.searchMode === "keywords" ? `,keywords:${encodedCompanyName}` : "";
1094
+ 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`;
1095
+ }
1096
+ function buildLinkedInAccountSearchApiUrl(companyName) {
683
1097
  const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
684
1098
  "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`;
1099
+ const encodedCompanyName = encodeURIComponent(companyName);
1100
+ 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`;
1101
+ }
1102
+ function buildLinkedInLookupSearchVariants(contact) {
1103
+ const variants = [];
1104
+ const seen = new Set();
1105
+ const companyCandidates = [
1106
+ normalizeLookupWhitespace(contact.companyName),
1107
+ normalizeLookupWhitespace(contact.companyNameOriginal)
1108
+ ].filter(Boolean);
1109
+ for (const companyName of companyCandidates) {
1110
+ for (const searchMode of ["current_company", "keywords"]) {
1111
+ const key = [
1112
+ contact.firstName.trim().toLowerCase(),
1113
+ contact.lastName.trim().toLowerCase(),
1114
+ companyName.toLowerCase(),
1115
+ searchMode
1116
+ ].join("|");
1117
+ if (seen.has(key)) {
1118
+ continue;
1119
+ }
1120
+ seen.add(key);
1121
+ variants.push({
1122
+ firstName: contact.firstName,
1123
+ lastName: contact.lastName,
1124
+ companyName,
1125
+ searchMode
1126
+ });
1127
+ }
1128
+ }
1129
+ return variants;
686
1130
  }
687
1131
  function extractLinkedInProfileUrlFromSalesApiElement(element) {
688
1132
  const entityUrn = typeof element?.entityUrn === "string" ? element.entityUrn : "";
689
1133
  const salesIdMatch = entityUrn.match(/\(([^,]+),/);
690
1134
  return salesIdMatch ? `https://www.linkedin.com/in/${salesIdMatch[1]}` : null;
691
1135
  }
1136
+ function collectNestedStrings(value, seen = new Set()) {
1137
+ if (value == null || seen.has(value)) {
1138
+ return [];
1139
+ }
1140
+ if (typeof value === "string") {
1141
+ return [value];
1142
+ }
1143
+ if (typeof value !== "object") {
1144
+ return [];
1145
+ }
1146
+ seen.add(value);
1147
+ if (Array.isArray(value)) {
1148
+ return value.flatMap((entry) => collectNestedStrings(entry, seen));
1149
+ }
1150
+ return Object.values(value).flatMap((entry) => collectNestedStrings(entry, seen));
1151
+ }
1152
+ function extractLinkedInCompanyUrlFromSalesApiElement(element) {
1153
+ if (!element) {
1154
+ return null;
1155
+ }
1156
+ const explicitUrlCandidates = [
1157
+ typeof element.companyPageUrl === "string" ? element.companyPageUrl : null,
1158
+ typeof element.regularCompanyUrl === "string" ? element.regularCompanyUrl : null,
1159
+ typeof element.url === "string" ? element.url : null
1160
+ ].filter(Boolean);
1161
+ const nestedUrlMatch = collectNestedStrings(element).find((value) => /https:\/\/www\.linkedin\.com\/company\/[^/?#]+/i.test(value));
1162
+ if (nestedUrlMatch) {
1163
+ return nestedUrlMatch.match(/https:\/\/www\.linkedin\.com\/company\/[^/?#]+/i)?.[0] ?? null;
1164
+ }
1165
+ for (const candidate of explicitUrlCandidates) {
1166
+ const normalized = normalizeLinkedInCompanyHandle(candidate);
1167
+ if (normalized) {
1168
+ return normalizeLinkedInCompanyPage(normalized);
1169
+ }
1170
+ }
1171
+ const entityUrn = typeof element.entityUrn === "string" ? element.entityUrn : "";
1172
+ const idMatch = entityUrn.match(/\(([^,]+),/);
1173
+ const companyId = idMatch?.[1]?.trim() ?? "";
1174
+ if (/^\d+$/.test(companyId)) {
1175
+ return normalizeLinkedInCompanyPage(companyId);
1176
+ }
1177
+ return null;
1178
+ }
1179
+ function extractLinkedInCompanyNameFromSalesApiElement(element) {
1180
+ if (!element) {
1181
+ return null;
1182
+ }
1183
+ const directCandidates = [
1184
+ typeof element.companyName === "string" ? element.companyName : null,
1185
+ typeof element.name === "string" ? element.name : null,
1186
+ typeof element.title === "string" ? element.title : null
1187
+ ].filter(Boolean);
1188
+ for (const candidate of directCandidates) {
1189
+ const normalized = normalizeLookupWhitespace(candidate);
1190
+ if (normalized) {
1191
+ return normalized;
1192
+ }
1193
+ }
1194
+ const nestedTitle = element.title;
1195
+ if (nestedTitle && typeof nestedTitle === "object" && "text" in nestedTitle) {
1196
+ const text = typeof nestedTitle.text === "string" ? normalizeLookupWhitespace(nestedTitle.text) : "";
1197
+ if (text) {
1198
+ return text;
1199
+ }
1200
+ }
1201
+ return null;
1202
+ }
1203
+ function extractLinkedInCompanyEmployeeCountFromSalesApiElement(element) {
1204
+ if (!element) {
1205
+ return null;
1206
+ }
1207
+ const numericCandidates = [
1208
+ typeof element.employeeCount === "number" ? element.employeeCount : null,
1209
+ typeof element.employeesOnLinkedInCount === "number" ? element.employeesOnLinkedInCount : null
1210
+ ].filter((value) => Number.isFinite(value));
1211
+ if (numericCandidates.length > 0) {
1212
+ return Math.max(0, Math.trunc(numericCandidates[0] ?? 0));
1213
+ }
1214
+ for (const value of collectNestedStrings(element)) {
1215
+ const match = value.match(/(\d[\d.,]*)\s+employees\b/i);
1216
+ if (match) {
1217
+ return Number(match[1].replace(/[.,]/g, ""));
1218
+ }
1219
+ }
1220
+ return null;
1221
+ }
1222
+ function buildLinkedInCompanyLookupVariants(params) {
1223
+ const variants = [];
1224
+ const seen = new Set();
1225
+ const rawCandidates = [
1226
+ normalizeLookupWhitespace(params.companyName),
1227
+ normalizeLookupWhitespace(params.companyNameOriginal)
1228
+ ].filter(Boolean);
1229
+ const normalizedCandidates = rawCandidates.flatMap((candidate) => {
1230
+ const aggressive = aggressivelyCleanLookupCompanyName(candidate);
1231
+ const searchable = normalizeLookupCompanyForSearch(candidate);
1232
+ const dePunctuated = normalizeLookupWhitespace(candidate.replace(/[^\p{L}\p{N}]+/gu, " "));
1233
+ return [candidate, aggressive, searchable, dePunctuated];
1234
+ });
1235
+ for (const companyName of normalizedCandidates) {
1236
+ if (!companyName) {
1237
+ continue;
1238
+ }
1239
+ const key = companyName.toLowerCase();
1240
+ if (seen.has(key)) {
1241
+ continue;
1242
+ }
1243
+ seen.add(key);
1244
+ variants.push({
1245
+ contact_id: params.contactId,
1246
+ companyName
1247
+ });
1248
+ }
1249
+ return variants;
1250
+ }
692
1251
  async function invokeLinkedInUrlEnrichmentDirect(params) {
693
- const config = readLinkedInDirectLookupConfig();
1252
+ const config = await readLinkedInDirectLookupConfig();
694
1253
  const groupedContacts = new Map();
695
1254
  for (const contact of params.contacts) {
696
1255
  const key = contact.email?.trim().toLowerCase() || `contact:${contact.contact_id}`;
@@ -722,23 +1281,119 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
722
1281
  let matchedUrl = null;
723
1282
  let lastError = null;
724
1283
  for (const candidate of variations) {
1284
+ for (const searchVariant of buildLinkedInLookupSearchVariants(candidate)) {
1285
+ const controller = new AbortController();
1286
+ const timeout = setTimeout(controller.abort.bind(controller), Math.min(params.timeoutMs, 20_000));
1287
+ try {
1288
+ const response = await fetch(buildLinkedInSalesApiUrl(searchVariant), {
1289
+ method: "GET",
1290
+ signal: controller.signal,
1291
+ headers: {
1292
+ accept: "*/*",
1293
+ "accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
1294
+ "csrf-token": config.csrfToken,
1295
+ referer: "https://www.linkedin.com/sales/search/people",
1296
+ "sec-fetch-dest": "empty",
1297
+ "sec-fetch-mode": "cors",
1298
+ "sec-fetch-site": "same-origin",
1299
+ "user-agent": config.userAgent,
1300
+ "x-li-identity": config.identity,
1301
+ "x-li-lang": "en_US",
1302
+ "x-restli-protocol-version": "2.0.0",
1303
+ cookie: config.cookie
1304
+ }
1305
+ });
1306
+ if (response.status === 429) {
1307
+ rateLimited = true;
1308
+ lastError = "LinkedIn rate limit";
1309
+ break;
1310
+ }
1311
+ if (!response.ok) {
1312
+ lastError = `LinkedIn returned ${response.status}`;
1313
+ continue;
1314
+ }
1315
+ const data = (await response.json());
1316
+ const profilesFound = data.paging?.total ?? 0;
1317
+ if (profilesFound > 0) {
1318
+ matchedUrl = extractLinkedInProfileUrlFromSalesApiElement(data.elements?.[0]) ?? null;
1319
+ if (matchedUrl) {
1320
+ break;
1321
+ }
1322
+ }
1323
+ }
1324
+ catch (error) {
1325
+ lastError = error instanceof Error ? error.message : "Unknown direct lookup error";
1326
+ }
1327
+ finally {
1328
+ clearTimeout(timeout);
1329
+ }
1330
+ if (matchedUrl || rateLimited) {
1331
+ break;
1332
+ }
1333
+ }
1334
+ if (matchedUrl || rateLimited) {
1335
+ break;
1336
+ }
1337
+ }
1338
+ results.push({
1339
+ contact_id: primary.contact_id,
1340
+ linkedin_url: matchedUrl,
1341
+ error: matchedUrl ? null : lastError
1342
+ });
1343
+ }
1344
+ return {
1345
+ success: true,
1346
+ contacts: results
1347
+ };
1348
+ }
1349
+ async function invokeLinkedInCompanyEnrichmentDirect(params) {
1350
+ const config = await readLinkedInDirectLookupConfig();
1351
+ const primaryContacts = new Map();
1352
+ for (const contact of params.contacts) {
1353
+ const existing = primaryContacts.get(contact.contact_id);
1354
+ if (!existing || existing.isVariation) {
1355
+ primaryContacts.set(contact.contact_id, contact);
1356
+ }
1357
+ }
1358
+ const results = [];
1359
+ let rateLimited = false;
1360
+ for (const contact of primaryContacts.values()) {
1361
+ if (rateLimited) {
1362
+ results.push({
1363
+ contact_id: contact.contact_id,
1364
+ linkedin_company_url: null,
1365
+ error: "LinkedIn rate limit"
1366
+ });
1367
+ continue;
1368
+ }
1369
+ const variants = buildLinkedInCompanyLookupVariants({
1370
+ contactId: contact.contact_id,
1371
+ companyName: contact.companyName,
1372
+ companyNameOriginal: contact.companyNameOriginal
1373
+ });
1374
+ let matchedCompanyUrl = null;
1375
+ let matchedCompanyName = null;
1376
+ let matchedCompanyEmployeeCount = null;
1377
+ let lastError = null;
1378
+ for (const variant of variants) {
725
1379
  const controller = new AbortController();
726
1380
  const timeout = setTimeout(controller.abort.bind(controller), Math.min(params.timeoutMs, 20_000));
727
1381
  try {
728
- const response = await fetch(buildLinkedInSalesApiUrl(candidate.firstName, candidate.lastName, candidate.companyName), {
1382
+ const response = await fetch(buildLinkedInAccountSearchApiUrl(variant.companyName), {
729
1383
  method: "GET",
730
1384
  signal: controller.signal,
731
1385
  headers: {
732
1386
  accept: "*/*",
733
1387
  "accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
734
1388
  "csrf-token": config.csrfToken,
735
- referer: "https://www.linkedin.com/sales/search/people",
1389
+ referer: "https://www.linkedin.com/sales/search/company",
736
1390
  "sec-fetch-dest": "empty",
737
1391
  "sec-fetch-mode": "cors",
738
1392
  "sec-fetch-site": "same-origin",
739
1393
  "user-agent": config.userAgent,
740
1394
  "x-li-identity": config.identity,
741
1395
  "x-li-lang": "en_US",
1396
+ "x-li-page-instance": "urn:li:page:d_sales2_search_accounts;13Jvve6kRGCao+iP0wwAag==",
742
1397
  "x-restli-protocol-version": "2.0.0",
743
1398
  cookie: config.cookie
744
1399
  }
@@ -749,29 +1404,35 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
749
1404
  break;
750
1405
  }
751
1406
  if (!response.ok) {
752
- lastError = `LinkedIn returned ${response.status}`;
1407
+ lastError = `LinkedIn company search returned ${response.status}`;
753
1408
  continue;
754
1409
  }
755
1410
  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
- }
1411
+ const first = data.elements?.[0];
1412
+ const companyUrl = extractLinkedInCompanyUrlFromSalesApiElement(first);
1413
+ if (companyUrl) {
1414
+ matchedCompanyUrl = companyUrl;
1415
+ matchedCompanyName = extractLinkedInCompanyNameFromSalesApiElement(first);
1416
+ matchedCompanyEmployeeCount = extractLinkedInCompanyEmployeeCountFromSalesApiElement(first);
1417
+ break;
762
1418
  }
763
1419
  }
764
1420
  catch (error) {
765
- lastError = error instanceof Error ? error.message : "Unknown direct lookup error";
1421
+ lastError = error instanceof Error ? error.message : "Unknown direct company lookup error";
766
1422
  }
767
1423
  finally {
768
1424
  clearTimeout(timeout);
769
1425
  }
1426
+ if (matchedCompanyUrl || rateLimited) {
1427
+ break;
1428
+ }
770
1429
  }
771
1430
  results.push({
772
- contact_id: primary.contact_id,
773
- linkedin_url: matchedUrl,
774
- error: matchedUrl ? null : lastError
1431
+ contact_id: contact.contact_id,
1432
+ linkedin_company_url: matchedCompanyUrl,
1433
+ matched_company_name: matchedCompanyName,
1434
+ matched_company_employee_count: matchedCompanyEmployeeCount,
1435
+ error: matchedCompanyUrl ? null : lastError
775
1436
  });
776
1437
  }
777
1438
  return {
@@ -869,13 +1530,15 @@ async function fetchSalesNavLookupCandidates(params) {
869
1530
  fullName: row.full_name == null ? null : String(row.full_name),
870
1531
  companyName: row.company_name == null ? null : String(row.company_name),
871
1532
  title: row.title == null ? null : String(row.title),
1533
+ companyUrl: row.company_url == null ? null : String(row.company_url),
1534
+ regularCompanyUrl: row.regular_company_url == null ? null : String(row.regular_company_url),
872
1535
  salesNavProfileUrl: row.sales_nav_profile_url == null ? null : String(row.sales_nav_profile_url),
873
1536
  linkedInProfileUrl: row.linkedin_profile_url == null ? null : String(row.linkedin_profile_url)
874
1537
  }));
875
1538
  const fetchRows = async (operator, value) => {
876
1539
  let query = supabase
877
1540
  .from("linkedin_sales_nav_people")
878
- .select("org_id,full_name,company_name,title,sales_nav_profile_url,linkedin_profile_url")
1541
+ .select("org_id,full_name,company_name,title,company_url,regular_company_url,sales_nav_profile_url,linkedin_profile_url")
879
1542
  .limit(10);
880
1543
  if (params.orgId?.trim()) {
881
1544
  query = query.eq("org_id", params.orgId.trim());
@@ -932,18 +1595,31 @@ async function resolveLinkedInUrlsFromSalesNavRows(params) {
932
1595
  });
933
1596
  const best = ranked[0]?.candidate;
934
1597
  const linkedinUrl = best?.linkedInProfileUrl ?? best?.salesNavProfileUrl ?? null;
1598
+ const linkedinCompanyUrl = (() => {
1599
+ const handle = normalizeLinkedInCompanyHandle(best?.regularCompanyUrl ?? "") ??
1600
+ normalizeLinkedInCompanyHandle(best?.companyUrl ?? "");
1601
+ if (handle) {
1602
+ return normalizeLinkedInCompanyPage(handle);
1603
+ }
1604
+ const numericCompanyUrl = typeof best?.companyUrl === "string" ? best.companyUrl.trim() : "";
1605
+ return numericCompanyUrl.length > 0 ? numericCompanyUrl : null;
1606
+ })();
935
1607
  results.push({
936
1608
  clientId: row.clientId,
937
1609
  fullName: row.fullName,
938
1610
  companyName: row.companyName,
939
1611
  linkedinUrl,
1612
+ linkedinCompanyUrl,
940
1613
  found: Boolean(linkedinUrl),
1614
+ companyFound: Boolean(linkedinCompanyUrl),
941
1615
  contactId: String(index + 1),
942
1616
  source: linkedinUrl ? "salesnav-supabase" : null,
1617
+ companySource: linkedinCompanyUrl ? "salesnav-supabase" : null,
943
1618
  matchedFullName: best?.fullName ?? null,
944
1619
  matchedCompanyName: best?.companyName ?? null,
945
1620
  matchedTitle: best?.title ?? null,
946
- matchedOrgId: best?.orgId ?? null
1621
+ matchedOrgId: best?.orgId ?? null,
1622
+ matchedCompanyEmployeeCount: null
947
1623
  });
948
1624
  }
949
1625
  return results;
@@ -1021,6 +1697,199 @@ function normalizeLinkedInCompanyHandle(value) {
1021
1697
  function normalizeLinkedInCompanyPage(handle) {
1022
1698
  return `https://www.linkedin.com/company/${handle}`;
1023
1699
  }
1700
+ function rewriteLinkedInUrlForConfiguredBase(url, env = process.env) {
1701
+ const overrideBase = env.SALESPROMPTER_LINKEDIN_BASE_URL?.trim();
1702
+ if (!overrideBase) {
1703
+ return url;
1704
+ }
1705
+ try {
1706
+ const source = new URL(url);
1707
+ if (!/(^|\.)linkedin\.com$/i.test(source.hostname)) {
1708
+ return url;
1709
+ }
1710
+ const targetBase = new URL(overrideBase);
1711
+ targetBase.pathname = source.pathname;
1712
+ targetBase.search = source.search;
1713
+ targetBase.hash = source.hash;
1714
+ return targetBase.toString();
1715
+ }
1716
+ catch {
1717
+ return url;
1718
+ }
1719
+ }
1720
+ function extractDirectLookupNameParts(contact) {
1721
+ return {
1722
+ firstName: normalizeLookupWhitespace(contact.firstName),
1723
+ lastName: normalizeLookupWhitespace(contact.lastName)
1724
+ };
1725
+ }
1726
+ async function resolveDomainFromLinkedInCompanyUrl(params) {
1727
+ const config = await readLinkedInDirectLookupConfig();
1728
+ const targetUrl = rewriteLinkedInUrlForConfiguredBase(params.companyUrl);
1729
+ const controller = new AbortController();
1730
+ const timeout = setTimeout(() => controller.abort(), Math.min(params.timeoutMs, 20_000));
1731
+ try {
1732
+ const response = await fetch(targetUrl, {
1733
+ method: "GET",
1734
+ signal: controller.signal,
1735
+ headers: {
1736
+ accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1737
+ "accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
1738
+ "user-agent": config.userAgent,
1739
+ cookie: config.cookie
1740
+ }
1741
+ });
1742
+ if (!response.ok) {
1743
+ return null;
1744
+ }
1745
+ const html = await response.text();
1746
+ const parsed = parseLinkedInCompanyPage(html, params.companyUrl);
1747
+ return parsed.domain ?? null;
1748
+ }
1749
+ catch (error) {
1750
+ if (error.name === "AbortError") {
1751
+ throw new Error(`LinkedIn company page lookup timed out after ${params.timeoutMs}ms.`);
1752
+ }
1753
+ return null;
1754
+ }
1755
+ finally {
1756
+ clearTimeout(timeout);
1757
+ }
1758
+ }
1759
+ async function buildDirectEmailEnrichmentBatch(params) {
1760
+ const parsedInput = directEmailEnrichmentInputRowArraySchema.parse(parseDirectEmailEnrichmentInput(params.rawInput));
1761
+ if (parsedInput.length === 0) {
1762
+ throw new Error("No contact rows found. Provide TSV/CSV/JSON input via --in.");
1763
+ }
1764
+ const clientId = resolveDirectEmailEnrichmentClientId(parsedInput, params.clientIdOption);
1765
+ const lookupRows = parsedInput.map((row) => ({
1766
+ clientId: String(clientId),
1767
+ fullName: row.fullName,
1768
+ companyName: row.companyName
1769
+ }));
1770
+ const cleanedCompanyMap = await buildCompanyNameCleaningMap(lookupRows, params.companyCleaningMode);
1771
+ const contacts = toLinkedInUrlLookupContacts(lookupRows, cleanedCompanyMap);
1772
+ let companyEnrichmentByName = new Map();
1773
+ if (!shouldBypassAuth()) {
1774
+ const session = await requireAuthSession().catch(() => null);
1775
+ if (session) {
1776
+ const uniqueCompanies = Array.from(new Map(contacts
1777
+ .filter((contact) => !contact.isVariation)
1778
+ .map((contact) => {
1779
+ const cleanedName = cleanedCompanyMap.get(normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName)) ??
1780
+ normalizeLookupWhitespace(contact.companyNameOriginal ?? contact.companyName);
1781
+ return [
1782
+ normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName),
1783
+ {
1784
+ companyId: contact.contact_id,
1785
+ companyName: contact.companyNameOriginal ?? contact.companyName,
1786
+ companyNameCleaned: cleanedName || null
1787
+ }
1788
+ ];
1789
+ })).values());
1790
+ if (uniqueCompanies.length > 0) {
1791
+ const enrichedCompanies = await enrichDirectEmailCompaniesViaApp(session, {
1792
+ clientId,
1793
+ companies: uniqueCompanies
1794
+ });
1795
+ companyEnrichmentByName = new Map(enrichedCompanies.companies.map((company) => [
1796
+ normalizeLookupCompanyForCleaning(company.companyName),
1797
+ {
1798
+ domain: company.domain ?? null,
1799
+ linkedinCompanyPage: company.linkedinCompanyPage ?? null
1800
+ }
1801
+ ]));
1802
+ }
1803
+ }
1804
+ }
1805
+ const [profileResult, companyResult] = await Promise.all([
1806
+ invokeLinkedInUrlEnrichmentDirect({
1807
+ contacts,
1808
+ timeoutMs: params.timeoutMs
1809
+ }),
1810
+ companyEnrichmentByName.size > 0
1811
+ ? Promise.resolve({
1812
+ success: true,
1813
+ contacts: contacts
1814
+ .filter((contact) => !contact.isVariation)
1815
+ .map((contact) => ({
1816
+ contact_id: contact.contact_id,
1817
+ linkedin_company_url: companyEnrichmentByName.get(normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName))?.linkedinCompanyPage ?? null,
1818
+ matched_company_name: null,
1819
+ matched_company_employee_count: null,
1820
+ error: null
1821
+ }))
1822
+ })
1823
+ : invokeLinkedInCompanyEnrichmentDirect({
1824
+ contacts,
1825
+ timeoutMs: params.timeoutMs
1826
+ })
1827
+ ]);
1828
+ const profileByContactId = new Map(profileResult.contacts.map((row) => [row.contact_id, row.linkedin_url ?? null]));
1829
+ const companyByContactId = new Map(companyResult.contacts.map((row) => [row.contact_id, row.linkedin_company_url ?? null]));
1830
+ const reportRows = [];
1831
+ const queueRows = [];
1832
+ for (let index = 0; index < contacts.length; index += 1) {
1833
+ const contact = contacts[index];
1834
+ if (contact.isVariation) {
1835
+ continue;
1836
+ }
1837
+ const names = extractDirectLookupNameParts(contact);
1838
+ const appCompanyEnrichment = companyEnrichmentByName.get(normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName)) ?? { domain: null, linkedinCompanyPage: null };
1839
+ const linkedinCompanyUrl = appCompanyEnrichment.linkedinCompanyPage ??
1840
+ companyByContactId.get(contact.contact_id) ??
1841
+ null;
1842
+ const domain = appCompanyEnrichment.domain ??
1843
+ (linkedinCompanyUrl
1844
+ ? await resolveDomainFromLinkedInCompanyUrl({
1845
+ companyUrl: linkedinCompanyUrl,
1846
+ timeoutMs: params.timeoutMs
1847
+ })
1848
+ : null);
1849
+ let skippedReason = null;
1850
+ if (!names.firstName || !names.lastName) {
1851
+ skippedReason = "missing-name";
1852
+ }
1853
+ else if (!domain) {
1854
+ skippedReason = "missing-domain";
1855
+ }
1856
+ const companyHandle = linkedinCompanyUrl ? normalizeLinkedInCompanyHandle(linkedinCompanyUrl) : null;
1857
+ const numericCompanyId = companyHandle && /^\d+$/.test(companyHandle) ? companyHandle : String(index + 1);
1858
+ const reportRow = {
1859
+ contactId: contact.contact_id,
1860
+ clientId: String(clientId),
1861
+ companyId: numericCompanyId,
1862
+ companyName: contact.companyNameOriginal ?? contact.companyName,
1863
+ fullName: normalizeLookupWhitespace(`${names.firstName} ${names.lastName}`),
1864
+ firstName: names.firstName,
1865
+ lastName: names.lastName,
1866
+ linkedinUrl: profileByContactId.get(contact.contact_id) ?? null,
1867
+ linkedinCompanyUrl,
1868
+ domain,
1869
+ ready: skippedReason == null,
1870
+ skippedReason
1871
+ };
1872
+ reportRows.push(reportRow);
1873
+ if (!reportRow.ready || !domain) {
1874
+ continue;
1875
+ }
1876
+ queueRows.push({
1877
+ clientId: String(clientId),
1878
+ contactId: contact.contact_id,
1879
+ companyId: numericCompanyId,
1880
+ firstName_cleaned: names.firstName,
1881
+ lastName_cleaned: names.lastName,
1882
+ domain,
1883
+ companyScore: null,
1884
+ seniorityId: null
1885
+ });
1886
+ }
1887
+ return {
1888
+ clientId,
1889
+ queueRows,
1890
+ reportRows
1891
+ };
1892
+ }
1024
1893
  function titleCaseSlug(value) {
1025
1894
  return value
1026
1895
  .split(/[-_]/)
@@ -2105,6 +2974,17 @@ async function launchLinkedInCompaniesBackfill(session, payload) {
2105
2974
  }), LinkedInCompanyBackfillLaunchResponseSchema);
2106
2975
  return value;
2107
2976
  }
2977
+ async function enrichDirectEmailCompaniesViaApp(session, payload) {
2978
+ const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/email-enrichment/companies`, {
2979
+ method: "POST",
2980
+ headers: {
2981
+ "Content-Type": "application/json",
2982
+ Authorization: `Bearer ${currentSession.accessToken}`
2983
+ },
2984
+ body: JSON.stringify(payload)
2985
+ }), CliEmailEnrichmentCompaniesResponseSchema);
2986
+ return value;
2987
+ }
2108
2988
  async function fetchLinkedInCompaniesBackfillStatus(session, payload) {
2109
2989
  const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/linkedin-companies/status?clientId=${encodeURIComponent(String(payload.clientId))}&containerId=${encodeURIComponent(payload.containerId)}`, {
2110
2990
  method: "GET",
@@ -3667,12 +4547,127 @@ program.configureHelp({
3667
4547
  });
3668
4548
  program.addHelpText("after", `
3669
4549
  LLM operator tips:
4550
+ - Install with: curl -fsSL https://docs.salesprompter.ai/install.sh | bash
4551
+ - First-run setup: salesprompter setup
4552
+ - Run a quick health check: salesprompter doctor
4553
+ - New here? Create your account at https://salesprompter.ai/sign-up, then run: salesprompter auth:login
3670
4554
  - Prefer non-interactive auth: set SALESPROMPTER_TOKEN (+ optional SALESPROMPTER_API_BASE_URL).
3671
4555
  - Use machine output for tools: add --json.
3672
4556
  - One-shot leads flow: leads:pipeline --icp <path> --domain <company.com> --count <n>.
3673
4557
  - Preview contact enrichment first: contacts:resolve-profiles --in <contacts.tsv> --dry-run.
3674
4558
  - For bigger runs, start with a small sample before processing the full file.
3675
4559
  `);
4560
+ program
4561
+ .command("setup")
4562
+ .description("Run the fastest first-run setup path for the Salesprompter CLI.")
4563
+ .option("--api-url <url>", "Salesprompter API base URL, defaults to SALESPROMPTER_API_BASE_URL or salesprompter.ai")
4564
+ .option("--timeout-seconds <number>", "Auth login timeout in seconds when setup needs to sign in", "180")
4565
+ .action(async (options) => {
4566
+ const timeoutSeconds = z.coerce.number().int().min(30).max(1800).parse(options.timeoutSeconds);
4567
+ printOutput({
4568
+ status: "ok",
4569
+ command: "setup",
4570
+ next: [
4571
+ "salesprompter auth:login",
4572
+ "salesprompter auth:whoami --verify",
4573
+ "salesprompter wizard"
4574
+ ],
4575
+ docs: "https://docs.salesprompter.ai/quickstart"
4576
+ });
4577
+ if (process.stdin.isTTY && process.stdout.isTTY && !runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
4578
+ await runWizard({
4579
+ apiUrl: options.apiUrl,
4580
+ timeoutSeconds
4581
+ });
4582
+ }
4583
+ });
4584
+ program
4585
+ .command("doctor")
4586
+ .description("Check local CLI prerequisites, auth state, and optional enrichment setup.")
4587
+ .action(async () => {
4588
+ const nodeMajor = Number(process.versions.node.split(".")[0] ?? "0");
4589
+ const readiness = await resolveLlmAuthReadiness();
4590
+ const hasOpenAiKey = Boolean(process.env.OPENAI_API_KEY || process.env.SALESPROMPTER_OPENAI_API_KEY);
4591
+ const hasLinkedInSession = Boolean(process.env.LINKEDIN_CSRF_TOKEN ||
4592
+ process.env.SALESPROMPTER_LINKEDIN_CSRF_TOKEN ||
4593
+ process.env.LINKEDIN_X_LI_IDENTITY ||
4594
+ process.env.SALESPROMPTER_LINKEDIN_X_LI_IDENTITY ||
4595
+ process.env.LINKEDIN_SALES_NAV_COOKIE ||
4596
+ process.env.SALESPROMPTER_LINKEDIN_SALES_NAV_COOKIE);
4597
+ printOutput({
4598
+ status: "ok",
4599
+ command: "doctor",
4600
+ checks: {
4601
+ node: {
4602
+ ok: nodeMajor >= 20,
4603
+ current: process.versions.node,
4604
+ required: ">=20.0.0"
4605
+ },
4606
+ auth: {
4607
+ ok: readiness.ready,
4608
+ mode: readiness.mode,
4609
+ apiBaseUrl: readiness.apiBaseUrl,
4610
+ reason: readiness.reason ?? null
4611
+ },
4612
+ companyCleaningAi: {
4613
+ ok: hasOpenAiKey,
4614
+ envVarPresent: hasOpenAiKey
4615
+ },
4616
+ linkedInSession: {
4617
+ ok: hasLinkedInSession,
4618
+ envVarPresent: hasLinkedInSession
4619
+ }
4620
+ },
4621
+ recommended: [
4622
+ readiness.ready ? null : "salesprompter auth:login",
4623
+ hasOpenAiKey ? null : "Set OPENAI_API_KEY to enable --company-cleaning ai",
4624
+ hasLinkedInSession ? null : "Set LINKEDIN_CSRF_TOKEN, LINKEDIN_X_LI_IDENTITY, and LINKEDIN_SALES_NAV_COOKIE for direct LinkedIn lookup"
4625
+ ].filter(Boolean)
4626
+ });
4627
+ });
4628
+ program
4629
+ .command("packs:list")
4630
+ .description("Show the product capability packs included in the CLI.")
4631
+ .action(() => {
4632
+ printOutput({
4633
+ status: "ok",
4634
+ packs: cliPacks
4635
+ });
4636
+ });
4637
+ program
4638
+ .command("packs:add")
4639
+ .description("Explain how to unlock a capability pack in the Salesprompter CLI.")
4640
+ .argument("<pack>", "Pack slug, for example contacts, research, discovery, or outreach")
4641
+ .action((pack) => {
4642
+ const normalized = String(pack).trim().toLowerCase();
4643
+ const match = cliPacks.find((entry) => entry.slug === normalized);
4644
+ if (!match) {
4645
+ throw new Error(`Unknown pack "${pack}". Run "salesprompter packs:list" to see the supported packs.`);
4646
+ }
4647
+ printOutput({
4648
+ status: "ok",
4649
+ pack: match.slug,
4650
+ title: match.title,
4651
+ available: true,
4652
+ installStatus: match.installStatus,
4653
+ commands: match.commands,
4654
+ message: `The ${match.title} pack is already included. Start with: salesprompter ${match.commands[0]}`
4655
+ });
4656
+ });
4657
+ program
4658
+ .command("upgrade")
4659
+ .description("Show the recommended upgrade command for the current installation.")
4660
+ .action(() => {
4661
+ printOutput({
4662
+ status: "ok",
4663
+ command: "upgrade",
4664
+ recommended: {
4665
+ npmGlobal: "npm i -g salesprompter-cli@latest",
4666
+ npx: "npx -y salesprompter-cli@latest",
4667
+ docs: "https://docs.salesprompter.ai/quickstart"
4668
+ }
4669
+ });
4670
+ });
3676
4671
  program
3677
4672
  .command("auth:login")
3678
4673
  .description("Authenticate CLI with a Salesprompter app token, or device flow if the app supports it.")
@@ -3773,6 +4768,7 @@ program
3773
4768
  .option("--org-id <id>", "Optional Sales Nav workspace org id for the lookup-first pass.")
3774
4769
  .option("--external-user-id <id>", "Deprecated compatibility option. Ignored by the direct CLI lookup.")
3775
4770
  .option("--timeout-ms <number>", "Lookup timeout in milliseconds", "30000")
4771
+ .option("--company-cleaning <mode>", "Company cleaning mode: off, basic, or ai", "basic")
3776
4772
  .option("--dry-run", "Preview the normalized payload without calling LinkedIn", false)
3777
4773
  .action(async (options) => {
3778
4774
  const timeoutMs = z.coerce.number().int().min(1000).max(300000).parse(options.timeoutMs);
@@ -3791,12 +4787,15 @@ program
3791
4787
  sessionOrgId = "";
3792
4788
  }
3793
4789
  }
3794
- const contacts = toLinkedInUrlLookupContacts(rows);
4790
+ const companyCleaningMode = resolveCompanyCleaningMode(String(options.companyCleaning ?? process.env.SALESPROMPTER_COMPANY_CLEANING_MODE ?? "basic"));
4791
+ const cleanedCompanyMap = await buildCompanyNameCleaningMap(rows, companyCleaningMode);
4792
+ const contacts = toLinkedInUrlLookupContacts(rows, cleanedCompanyMap);
3795
4793
  if (options.dryRun) {
3796
4794
  const payload = {
3797
4795
  status: "ok",
3798
4796
  dryRun: true,
3799
4797
  orgId: String(options.orgId ?? "").trim() || null,
4798
+ companyCleaningMode,
3800
4799
  contacts: contacts.length,
3801
4800
  sample: contacts.slice(0, 5)
3802
4801
  };
@@ -3831,11 +4830,41 @@ program
3831
4830
  }
3832
4831
  }
3833
4832
  }
4833
+ try {
4834
+ const companyResult = await invokeLinkedInCompanyEnrichmentDirect({
4835
+ contacts,
4836
+ timeoutMs
4837
+ });
4838
+ const companyByContactId = new Map(companyResult.contacts.map((contact) => [
4839
+ contact.contact_id,
4840
+ {
4841
+ linkedinCompanyUrl: contact.linkedin_company_url ?? null,
4842
+ matchedCompanyName: contact.matched_company_name ?? null,
4843
+ matchedCompanyEmployeeCount: contact.matched_company_employee_count ?? null
4844
+ }
4845
+ ]));
4846
+ for (const row of enrichedRows) {
4847
+ const company = companyByContactId.get(row.contactId);
4848
+ if (!company || row.linkedinCompanyUrl) {
4849
+ continue;
4850
+ }
4851
+ row.linkedinCompanyUrl = company.linkedinCompanyUrl;
4852
+ row.companyFound = Boolean(company.linkedinCompanyUrl);
4853
+ row.companySource = company.linkedinCompanyUrl ? "linkedin-direct" : row.companySource ?? null;
4854
+ row.matchedCompanyName = company.matchedCompanyName ?? row.matchedCompanyName ?? null;
4855
+ row.matchedCompanyEmployeeCount =
4856
+ company.matchedCompanyEmployeeCount ?? row.matchedCompanyEmployeeCount ?? null;
4857
+ }
4858
+ }
4859
+ catch (error) {
4860
+ writeProgress(`Skipping separate company enrichment: ${error instanceof Error ? error.message : String(error)}`);
4861
+ }
3834
4862
  const payload = {
3835
4863
  status: "ok",
3836
4864
  orgId: String(options.orgId ?? "").trim() || null,
3837
4865
  requested: rows.length,
3838
4866
  found: enrichedRows.filter((row) => row.found).length,
4867
+ companiesFound: enrichedRows.filter((row) => row.companyFound).length,
3839
4868
  directAttempted,
3840
4869
  rows: enrichedRows
3841
4870
  };
@@ -3854,10 +4883,18 @@ program
3854
4883
  program.hook("preAction", async (_thisCommand, actionCommand) => {
3855
4884
  applyGlobalOutputOptions(actionCommand);
3856
4885
  const commandName = actionCommand.name();
4886
+ const parentCommandName = actionCommand.parent && typeof actionCommand.parent.name === "function"
4887
+ ? actionCommand.parent.name()
4888
+ : "";
3857
4889
  if (commandName.startsWith("auth:") ||
4890
+ commandName === "setup" ||
4891
+ commandName === "doctor" ||
4892
+ commandName === "upgrade" ||
3858
4893
  commandName === "wizard" ||
3859
4894
  commandName === "llm:ready" ||
3860
- commandName === "contacts:find-linkedin-urls") {
4895
+ commandName === "contacts:find-linkedin-urls" ||
4896
+ commandName.startsWith("packs:") ||
4897
+ ((commandName === "list" || commandName === "add") && parentCommandName === "packs")) {
3861
4898
  return;
3862
4899
  }
3863
4900
  const commandOptions = actionCommand.opts();
@@ -5818,6 +6855,123 @@ program
5818
6855
  execution
5819
6856
  });
5820
6857
  });
6858
+ program
6859
+ .command("contacts:process-emails")
6860
+ .alias("contacts:resolve-emails")
6861
+ .alias("hunter-emailfinder:run:bq")
6862
+ .description("Process the next batch of contact email enrichment for a workspace.")
6863
+ .option("--client-id <number>", "Queue clientId to process")
6864
+ .option("--in <path>", "Optional TSV/CSV/JSON input path with company and contact rows")
6865
+ .option("--limit <number>", "Max queue rows to fetch", "500")
6866
+ .requiredOption("--out-dir <path>", "Output directory for artifacts")
6867
+ .option("--trace-id <traceId>", "Trace id for this batch")
6868
+ .option("--endpoint-url <url>", "Override the enrichment workflow endpoint")
6869
+ .option("--timeout-ms <number>", "Workflow trigger timeout in milliseconds", "30000")
6870
+ .option("--company-cleaning <mode>", "Company cleaning mode for direct input: off, basic, or ai", "basic")
6871
+ .option("--dry-run", "Preview the next batch without starting background processing", false)
6872
+ .action(async (options) => {
6873
+ const limit = z.coerce.number().int().min(1).max(50000).parse(options.limit);
6874
+ const outDir = z.string().min(1).parse(options.outDir);
6875
+ const timeoutMs = z.coerce.number().int().min(1000).max(300000).parse(options.timeoutMs);
6876
+ const usingDirectInput = typeof options.in === "string" && options.in.trim().length > 0;
6877
+ const companyCleaningMode = resolveCompanyCleaningMode(String(options.companyCleaning ?? process.env.SALESPROMPTER_COMPANY_CLEANING_MODE ?? "basic"));
6878
+ let clientId;
6879
+ let queueRows;
6880
+ let directReportRows = null;
6881
+ let queueSqlPath = null;
6882
+ let directReportPath = null;
6883
+ if (usingDirectInput) {
6884
+ const rawInput = await readFile(options.in, "utf8");
6885
+ const directBatch = await buildDirectEmailEnrichmentBatch({
6886
+ rawInput,
6887
+ clientIdOption: options.clientId,
6888
+ timeoutMs,
6889
+ companyCleaningMode
6890
+ });
6891
+ clientId = directBatch.clientId;
6892
+ queueRows = directBatch.queueRows.slice(0, limit);
6893
+ directReportRows = directBatch.reportRows;
6894
+ }
6895
+ else {
6896
+ clientId = z.coerce.number().int().positive().parse(options.clientId);
6897
+ queueSqlPath = `${outDir}/email-enrichment-input-${clientId}.sql`;
6898
+ const queueSql = buildHunterEmailfinderQueueSql(clientId, limit);
6899
+ await writeTextFile(queueSqlPath, `${queueSql}\n`);
6900
+ const rawQueueRows = await runBigQueryRows(queueSql, { maxRows: limit });
6901
+ queueRows = hunterEmailfinderQueueRowArraySchema.parse(rawQueueRows);
6902
+ }
6903
+ const traceId = z.string().min(1).parse(options.traceId ?? `salesprompter-cli-email-${clientId}-${Date.now()}`);
6904
+ const queueRowsPath = `${outDir}/email-enrichment-input-${clientId}.json`;
6905
+ const requestPath = `${outDir}/email-enrichment-request-${clientId}.json`;
6906
+ const responsePath = `${outDir}/email-enrichment-response-${clientId}.json`;
6907
+ await writeJsonFile(queueRowsPath, queueRows);
6908
+ if (directReportRows) {
6909
+ directReportPath = `${outDir}/email-enrichment-direct-${clientId}.json`;
6910
+ await writeJsonFile(directReportPath, {
6911
+ clientId,
6912
+ companyCleaningMode,
6913
+ rows: directReportRows
6914
+ });
6915
+ }
6916
+ const payload = buildHunterEmailfinderTriggerPayload({
6917
+ traceId,
6918
+ clientId: String(clientId),
6919
+ queueRows
6920
+ });
6921
+ await writeJsonFile(requestPath, {
6922
+ traceId,
6923
+ clientId,
6924
+ queueRows: queueRows.length,
6925
+ payload
6926
+ });
6927
+ let trigger = null;
6928
+ if (!options.dryRun && queueRows.length > 0) {
6929
+ const config = readHunterEmailfinderConfig(process.env, options.endpointUrl);
6930
+ const result = await triggerHunterEmailfinderWorkflow({
6931
+ config,
6932
+ externalUserId: `email-enrichment-client-${clientId}`,
6933
+ timeoutMs,
6934
+ payload
6935
+ });
6936
+ trigger = {
6937
+ triggered: result.response.ok,
6938
+ status: result.response.status
6939
+ };
6940
+ await writeJsonFile(responsePath, {
6941
+ traceId,
6942
+ clientId,
6943
+ status: result.response.status,
6944
+ ok: result.response.ok,
6945
+ endpoint: result.endpoint,
6946
+ body: result.parsedBody
6947
+ });
6948
+ if (!result.response.ok) {
6949
+ throw new Error(`Email enrichment workflow returned ${result.response.status}: ${result.bodyText || "No response body"}`);
6950
+ }
6951
+ }
6952
+ printOutput({
6953
+ status: "ok",
6954
+ clientId,
6955
+ limit,
6956
+ traceId,
6957
+ dryRun: Boolean(options.dryRun),
6958
+ mode: usingDirectInput ? "direct-input" : "workspace-queue",
6959
+ companyCleaningMode: usingDirectInput ? companyCleaningMode : null,
6960
+ contactsRead: directReportRows?.length ?? null,
6961
+ contactsQueued: queueRows.length,
6962
+ started: Boolean(trigger?.triggered),
6963
+ profilesFound: directReportRows ? directReportRows.filter((row) => row.linkedinUrl).length : null,
6964
+ companiesFound: directReportRows ? directReportRows.filter((row) => row.linkedinCompanyUrl).length : null,
6965
+ domainsFound: directReportRows ? directReportRows.filter((row) => row.domain).length : null,
6966
+ artifacts: {
6967
+ inputSqlPath: queueSqlPath,
6968
+ inputRowsPath: queueRowsPath,
6969
+ directReportPath,
6970
+ requestPath,
6971
+ responsePath: trigger ? responsePath : null
6972
+ }
6973
+ });
6974
+ });
5821
6975
  async function main() {
5822
6976
  if (shouldAutoRunWizard(process.argv)) {
5823
6977
  await runWizard();