salesprompter-cli 0.1.24 → 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/README.md +10 -5
- package/dist/cli.js +1031 -34
- package/dist/hunter-emailfinder.js +252 -0
- package/package.json +2 -2
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
|
|
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
|
|
668
|
-
|
|
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
|
-
|
|
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(
|
|
1049
|
+
function buildLinkedInSalesApiUrl(params) {
|
|
683
1050
|
const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
|
|
684
1051
|
"https://www.linkedin.com";
|
|
685
|
-
|
|
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(
|
|
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/
|
|
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
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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:
|
|
773
|
-
|
|
774
|
-
|
|
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
|
|
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();
|