linkedin-secret-sauce 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,4 +44,4 @@ export { extractNumericLinkedInId } from "./providers/ldd";
44
44
  export { createSmartProspectClient, type SmartProspectClient, type SmartProspectLocationOptions, } from "./providers/smartprospect";
45
45
  export { enrichBusinessEmail, enrichBatch, enrichAllEmails, enrichAllBatch } from "./orchestrator";
46
46
  export { getSmartLeadToken, getSmartLeadUser, clearSmartLeadToken, clearAllSmartLeadTokens, getSmartLeadTokenCacheStats, enableFileCache, disableFileCache, isFileCacheEnabled, clearFileCache, type SmartLeadCredentials, type SmartLeadAuthConfig, type SmartLeadUser, type SmartLeadLoginResponse, } from "./auth";
47
- export { calculateMatchConfidence, classifyMatchQuality, findBestMatch, matchContacts, buildSmartProspectFiltersFromLinkedIn, parseLinkedInSearchResponse, enrichLinkedInContact, enrichLinkedInContactsBatch, createLinkedInEnricher, getEmailsForLinkedInContact, getEmailsForLinkedInContactsBatch, type LinkedInContact, type MatchResult, type MatchOptions, type LinkedInEnrichmentResult, type LinkedInEnrichmentOptions, type EmailResult, type GetEmailsResult, type GetEmailsConfig, type GetEmailsOptions, } from "./matching";
47
+ export { calculateMatchConfidence, classifyMatchQuality, findBestMatch, matchContacts, buildSmartProspectFiltersFromLinkedIn, parseLinkedInSearchResponse, enrichLinkedInContact, enrichLinkedInContactsBatch, createLinkedInEnricher, getEmailsForLinkedInContact, getEmailsForLinkedInContactsBatch, salesLeadToContact, type LinkedInContact, type MatchResult, type MatchOptions, type LinkedInEnrichmentResult, type LinkedInEnrichmentOptions, type EmailSource, type EmailResult, type GetEmailsResult, type GetEmailsConfig, type GetEmailsOptions, } from "./matching";
@@ -42,7 +42,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
42
42
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
43
43
  };
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
- exports.getEmailsForLinkedInContactsBatch = exports.getEmailsForLinkedInContact = exports.createLinkedInEnricher = exports.enrichLinkedInContactsBatch = exports.enrichLinkedInContact = exports.parseLinkedInSearchResponse = exports.buildSmartProspectFiltersFromLinkedIn = exports.matchContacts = exports.findBestMatch = exports.classifyMatchQuality = exports.calculateMatchConfidence = exports.clearFileCache = exports.isFileCacheEnabled = exports.disableFileCache = exports.enableFileCache = exports.getSmartLeadTokenCacheStats = exports.clearAllSmartLeadTokens = exports.clearSmartLeadToken = exports.getSmartLeadUser = exports.getSmartLeadToken = exports.enrichAllBatch = exports.enrichAllEmails = exports.enrichBatch = exports.enrichBusinessEmail = exports.createSmartProspectClient = exports.extractNumericLinkedInId = exports.createDropcontactProvider = exports.createApolloProvider = exports.createHunterProvider = exports.createSmartProspectProvider = exports.createLddProvider = exports.createConstructProvider = exports.verifyEmailMx = exports.extractLinkedInUsername = exports.hostnameFromUrl = exports.cleanNamePart = exports.asciiFold = exports.isRoleAccount = exports.isValidEmailSyntax = exports.DISPOSABLE_DOMAINS = exports.isDisposableDomain = exports.isDisposableEmail = exports.PERSONAL_DOMAINS = exports.isPersonalDomain = exports.isBusinessEmail = exports.isPersonalEmail = void 0;
45
+ exports.salesLeadToContact = exports.getEmailsForLinkedInContactsBatch = exports.getEmailsForLinkedInContact = exports.createLinkedInEnricher = exports.enrichLinkedInContactsBatch = exports.enrichLinkedInContact = exports.parseLinkedInSearchResponse = exports.buildSmartProspectFiltersFromLinkedIn = exports.matchContacts = exports.findBestMatch = exports.classifyMatchQuality = exports.calculateMatchConfidence = exports.clearFileCache = exports.isFileCacheEnabled = exports.disableFileCache = exports.enableFileCache = exports.getSmartLeadTokenCacheStats = exports.clearAllSmartLeadTokens = exports.clearSmartLeadToken = exports.getSmartLeadUser = exports.getSmartLeadToken = exports.enrichAllBatch = exports.enrichAllEmails = exports.enrichBatch = exports.enrichBusinessEmail = exports.createSmartProspectClient = exports.extractNumericLinkedInId = exports.createDropcontactProvider = exports.createApolloProvider = exports.createHunterProvider = exports.createSmartProspectProvider = exports.createLddProvider = exports.createConstructProvider = exports.verifyEmailMx = exports.extractLinkedInUsername = exports.hostnameFromUrl = exports.cleanNamePart = exports.asciiFold = exports.isRoleAccount = exports.isValidEmailSyntax = exports.DISPOSABLE_DOMAINS = exports.isDisposableDomain = exports.isDisposableEmail = exports.PERSONAL_DOMAINS = exports.isPersonalDomain = exports.isBusinessEmail = exports.isPersonalEmail = void 0;
46
46
  exports.createEnrichmentClient = createEnrichmentClient;
47
47
  const orchestrator_1 = require("./orchestrator");
48
48
  const construct_1 = require("./providers/construct");
@@ -290,3 +290,5 @@ Object.defineProperty(exports, "createLinkedInEnricher", { enumerable: true, get
290
290
  // UNIFIED email lookup (recommended for most use cases)
291
291
  Object.defineProperty(exports, "getEmailsForLinkedInContact", { enumerable: true, get: function () { return matching_1.getEmailsForLinkedInContact; } });
292
292
  Object.defineProperty(exports, "getEmailsForLinkedInContactsBatch", { enumerable: true, get: function () { return matching_1.getEmailsForLinkedInContactsBatch; } });
293
+ // Conversion utility for Sales Nav entities
294
+ Object.defineProperty(exports, "salesLeadToContact", { enumerable: true, get: function () { return matching_1.salesLeadToContact; } });
@@ -5,6 +5,7 @@
5
5
  * to find the same person across both platforms.
6
6
  */
7
7
  import type { SmartProspectContact, SmartProspectConfig, SmartProspectFetchResponse, LddConfig } from './types';
8
+ import type { SalesLeadSearchResult } from '../types';
8
9
  import { type SmartProspectClient } from './providers/smartprospect';
9
10
  /**
10
11
  * LinkedIn Sales Navigator contact (simplified from API response)
@@ -39,6 +40,21 @@ export interface LinkedInContact {
39
40
  }>;
40
41
  };
41
42
  }
43
+ /**
44
+ * Convert a Sales Navigator search result to LinkedInContact format.
45
+ * This allows consumers to pass raw Sales Nav entities directly.
46
+ *
47
+ * @param lead - Raw Sales Navigator lead search result
48
+ * @returns LinkedInContact compatible with email lookup functions
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * const searchResults = await searchSalesLeads('CEO', { filters });
53
+ * const contact = salesLeadToContact(searchResults.items[0]);
54
+ * const emails = await getEmailsForLinkedInContact(contact, config);
55
+ * ```
56
+ */
57
+ export declare function salesLeadToContact(lead: SalesLeadSearchResult): LinkedInContact;
42
58
  /**
43
59
  * Match result with confidence score
44
60
  */
@@ -239,6 +255,10 @@ export declare function createLinkedInEnricher(smartProspectConfig: SmartProspec
239
255
  /** Fetch email for a specific SmartProspect contact ID (COSTS CREDITS) */
240
256
  fetchEmail: (contactId: string) => Promise<SmartProspectFetchResponse>;
241
257
  } | null;
258
+ /**
259
+ * Email source - where the email was found
260
+ */
261
+ export type EmailSource = 'ldd' | 'smartprospect' | 'linkedin' | 'pattern' | 'hunter' | 'apollo';
242
262
  /**
243
263
  * Email result from unified lookup
244
264
  */
@@ -246,7 +266,7 @@ export interface EmailResult {
246
266
  /** The email address */
247
267
  email: string;
248
268
  /** Which provider found this email */
249
- source: 'ldd' | 'smartprospect';
269
+ source: EmailSource;
250
270
  /** Confidence score (0-100) */
251
271
  confidence: number;
252
272
  /** Email type classification */
@@ -255,6 +275,8 @@ export interface EmailResult {
255
275
  verified: boolean;
256
276
  /** Email deliverability score (0-1) for SmartProspect emails */
257
277
  deliverability?: number;
278
+ /** Whether this is a catch-all domain */
279
+ isCatchAll?: boolean;
258
280
  /** Additional metadata */
259
281
  metadata?: Record<string, unknown>;
260
282
  }
@@ -269,18 +291,38 @@ export interface GetEmailsResult {
269
291
  /** Numeric LinkedIn ID extracted from objectUrn */
270
292
  numericLinkedInId: string | null;
271
293
  /** Which providers were queried */
272
- providersQueried: ('ldd' | 'smartprospect')[];
273
- /** Error message if failed */
274
- error?: string;
294
+ providersQueried: EmailSource[];
295
+ /** Company domain discovered during lookup (from SmartProspect if not in LinkedIn data) */
296
+ discoveredCompanyDomain?: string;
297
+ /** Error message if any provider failed */
298
+ errors?: string[];
275
299
  }
276
300
  /**
277
301
  * Configuration for unified email lookup
278
302
  */
279
303
  export interface GetEmailsConfig {
280
- /** LDD configuration (optional but recommended - FREE) */
304
+ /** LDD configuration (FREE - your ~500M database) */
281
305
  ldd?: LddConfig;
282
- /** SmartProspect configuration (optional - PAID fallback) */
306
+ /** SmartProspect configuration (FREE for FlexIQ - already paying monthly) */
283
307
  smartprospect?: SmartProspectConfig;
308
+ /** Company domain for email pattern guessing (optional - if not provided, will try to discover) */
309
+ companyDomain?: string;
310
+ /**
311
+ * LinkedIn company lookup function (for fetching company website when not available).
312
+ * Pass getCompanyById from linkedin-api to enable this.
313
+ * This is a LAST RESORT since it uses LinkedIn API quota.
314
+ */
315
+ linkedInCompanyLookup?: (companyId: string) => Promise<{
316
+ websiteUrl?: string;
317
+ } | null>;
318
+ /** Hunter configuration (PAID - last resort) */
319
+ hunter?: {
320
+ apiKey: string;
321
+ };
322
+ /** Apollo configuration (PAID - last resort) */
323
+ apollo?: {
324
+ apiKey: string;
325
+ };
284
326
  }
285
327
  /**
286
328
  * Options for unified email lookup
@@ -290,19 +332,26 @@ export interface GetEmailsOptions {
290
332
  skipLdd?: boolean;
291
333
  /** Skip SmartProspect lookup (default: false) */
292
334
  skipSmartProspect?: boolean;
335
+ /** Skip email pattern guessing (default: false) */
336
+ skipPatternGuessing?: boolean;
337
+ /** Skip LinkedIn company lookup for domain discovery (default: false) */
338
+ skipLinkedInCompanyLookup?: boolean;
339
+ /** Skip paid providers Hunter/Apollo (default: false) */
340
+ skipPaidProviders?: boolean;
293
341
  /** Minimum match confidence for SmartProspect (default: 60) */
294
342
  minMatchConfidence?: number;
343
+ /** Minimum confidence to skip paid providers (default: 80) */
344
+ paidProviderThreshold?: number;
295
345
  /** Include company in SmartProspect search (default: true) */
296
346
  includeCompany?: boolean;
297
347
  }
298
348
  /**
299
349
  * Get emails for a LinkedIn contact - UNIFIED FUNCTION
300
350
  *
301
- * This is the main function consumers should use. It:
302
- * 1. Extracts the numeric LinkedIn ID from objectUrn (stable identifier)
303
- * 2. Tries LDD first (FREE, uses numeric ID for reliable lookup)
304
- * 3. Falls back to SmartProspect if LDD has no results (PAID)
305
- * 4. Returns ALL emails with unified scoring
351
+ * Strategy (optimized for FlexIQ):
352
+ * 1. Query LDD + SmartProspect in PARALLEL (both FREE for you)
353
+ * 2. Try email pattern guessing with MX verification (FREE)
354
+ * 3. Only use Hunter/Apollo if confidence is below threshold (PAID - last resort)
306
355
  *
307
356
  * @example
308
357
  * ```ts
@@ -310,10 +359,13 @@ export interface GetEmailsOptions {
310
359
  *
311
360
  * // From your LinkedIn Sales Nav search result
312
361
  * const contact = {
313
- * objectUrn: 'urn:li:member:307567', // IMPORTANT: Include this!
362
+ * objectUrn: 'urn:li:member:307567', // Stable ID for LDD
314
363
  * firstName: 'Jim',
315
364
  * lastName: 'DeMaio',
316
- * currentPositions: [{ companyName: 'Acme Corp', title: 'CEO' }]
365
+ * currentPositions: [{
366
+ * companyName: 'Acme Corp',
367
+ * companyUrnResolutionResult: { website: 'acme.com' }
368
+ * }]
317
369
  * };
318
370
  *
319
371
  * const result = await getEmailsForLinkedInContact(contact, {
@@ -325,26 +377,31 @@ export interface GetEmailsOptions {
325
377
  * email: process.env.SMARTLEAD_EMAIL,
326
378
  * password: process.env.SMARTLEAD_PASSWORD,
327
379
  * },
380
+ * // companyDomain extracted automatically from currentPositions
328
381
  * });
329
382
  *
330
383
  * if (result.success) {
384
+ * console.log('Providers queried:', result.providersQueried);
331
385
  * console.log('Found emails:', result.emails);
332
386
  * // [
333
387
  * // { email: 'jim@acme.com', source: 'ldd', confidence: 95, type: 'business' },
334
- * // { email: 'jim@gmail.com', source: 'ldd', confidence: 70, type: 'personal' }
388
+ * // { email: 'j.demaio@acme.com', source: 'smartprospect', confidence: 85, type: 'business' },
335
389
  * // ]
336
390
  * }
337
391
  * ```
338
392
  */
339
- export declare function getEmailsForLinkedInContact(contact: LinkedInContact, config: GetEmailsConfig, options?: GetEmailsOptions): Promise<GetEmailsResult>;
393
+ export declare function getEmailsForLinkedInContact(contactOrLead: LinkedInContact | SalesLeadSearchResult, config: GetEmailsConfig, options?: GetEmailsOptions): Promise<GetEmailsResult>;
340
394
  /**
341
395
  * Get emails for multiple LinkedIn contacts in batch
342
396
  *
343
- * Processes contacts sequentially to avoid rate limits.
397
+ * Processes contacts with controlled concurrency to balance speed and rate limits.
398
+ * Accepts either LinkedInContact[] or SalesLeadSearchResult[] directly from Sales Nav search.
344
399
  */
345
- export declare function getEmailsForLinkedInContactsBatch(contacts: LinkedInContact[], config: GetEmailsConfig, options?: GetEmailsOptions & {
346
- /** Delay between requests in ms (default: 200) */
400
+ export declare function getEmailsForLinkedInContactsBatch(contactsOrLeads: (LinkedInContact | SalesLeadSearchResult)[], config: GetEmailsConfig, options?: GetEmailsOptions & {
401
+ /** Delay between requests in ms (default: 100) */
347
402
  delayMs?: number;
403
+ /** Max concurrent lookups (default: 3) */
404
+ concurrency?: number;
348
405
  /** Callback for progress updates */
349
406
  onProgress?: (completed: number, total: number, result: GetEmailsResult) => void;
350
407
  }): Promise<GetEmailsResult[]>;
@@ -5,7 +5,41 @@
5
5
  * Matches contacts between LinkedIn Sales Navigator and SmartProspect
6
6
  * to find the same person across both platforms.
7
7
  */
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
20
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
21
+ }) : function(o, v) {
22
+ o["default"] = v;
23
+ });
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
8
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.salesLeadToContact = salesLeadToContact;
9
43
  exports.calculateMatchConfidence = calculateMatchConfidence;
10
44
  exports.classifyMatchQuality = classifyMatchQuality;
11
45
  exports.findBestMatch = findBestMatch;
@@ -19,6 +53,64 @@ exports.getEmailsForLinkedInContact = getEmailsForLinkedInContact;
19
53
  exports.getEmailsForLinkedInContactsBatch = getEmailsForLinkedInContactsBatch;
20
54
  const smartprospect_1 = require("./providers/smartprospect");
21
55
  const ldd_1 = require("./providers/ldd");
56
+ /**
57
+ * Convert a Sales Navigator search result to LinkedInContact format.
58
+ * This allows consumers to pass raw Sales Nav entities directly.
59
+ *
60
+ * @param lead - Raw Sales Navigator lead search result
61
+ * @returns LinkedInContact compatible with email lookup functions
62
+ *
63
+ * @example
64
+ * ```typescript
65
+ * const searchResults = await searchSalesLeads('CEO', { filters });
66
+ * const contact = salesLeadToContact(searchResults.items[0]);
67
+ * const emails = await getEmailsForLinkedInContact(contact, config);
68
+ * ```
69
+ */
70
+ function salesLeadToContact(lead) {
71
+ // Use firstName/lastName directly from Sales Nav - they're already separate fields
72
+ // This preserves exact names as shown on LinkedIn (important for SmartProspect matching)
73
+ const firstName = lead.firstName || '';
74
+ const lastName = lead.lastName || '';
75
+ return {
76
+ objectUrn: lead.objectUrn,
77
+ entityUrn: lead.salesProfileUrn,
78
+ firstName,
79
+ lastName,
80
+ fullName: lead.fullName || lead.name,
81
+ geoRegion: lead.geoRegion || lead.locationText,
82
+ currentPositions: lead.currentPositions?.map((pos) => ({
83
+ companyName: pos.companyName,
84
+ title: pos.title,
85
+ companyUrn: pos.companyUrn,
86
+ // Note: Sales Nav search does NOT return website in companyUrnResolutionResult
87
+ // We use LinkedIn Company Lookup to get website from companyUrn
88
+ companyUrnResolutionResult: pos.companyUrnResolutionResult
89
+ ? {
90
+ name: pos.companyUrnResolutionResult.name,
91
+ industry: pos.companyUrnResolutionResult.industry,
92
+ location: pos.companyUrnResolutionResult.location,
93
+ }
94
+ : undefined,
95
+ })),
96
+ };
97
+ }
98
+ /**
99
+ * Type guard to check if input is a SalesLeadSearchResult
100
+ */
101
+ function isSalesLeadSearchResult(input) {
102
+ // SalesLeadSearchResult has 'name' but not 'firstName'
103
+ return 'name' in input && !('firstName' in input);
104
+ }
105
+ /**
106
+ * Normalize input to LinkedInContact - accepts either format
107
+ */
108
+ function normalizeToContact(input) {
109
+ if (isSalesLeadSearchResult(input)) {
110
+ return salesLeadToContact(input);
111
+ }
112
+ return input;
113
+ }
22
114
  // =============================================================================
23
115
  // Utility Functions
24
116
  // =============================================================================
@@ -629,11 +721,10 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
629
721
  /**
630
722
  * Get emails for a LinkedIn contact - UNIFIED FUNCTION
631
723
  *
632
- * This is the main function consumers should use. It:
633
- * 1. Extracts the numeric LinkedIn ID from objectUrn (stable identifier)
634
- * 2. Tries LDD first (FREE, uses numeric ID for reliable lookup)
635
- * 3. Falls back to SmartProspect if LDD has no results (PAID)
636
- * 4. Returns ALL emails with unified scoring
724
+ * Strategy (optimized for FlexIQ):
725
+ * 1. Query LDD + SmartProspect in PARALLEL (both FREE for you)
726
+ * 2. Try email pattern guessing with MX verification (FREE)
727
+ * 3. Only use Hunter/Apollo if confidence is below threshold (PAID - last resort)
637
728
  *
638
729
  * @example
639
730
  * ```ts
@@ -641,10 +732,13 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
641
732
  *
642
733
  * // From your LinkedIn Sales Nav search result
643
734
  * const contact = {
644
- * objectUrn: 'urn:li:member:307567', // IMPORTANT: Include this!
735
+ * objectUrn: 'urn:li:member:307567', // Stable ID for LDD
645
736
  * firstName: 'Jim',
646
737
  * lastName: 'DeMaio',
647
- * currentPositions: [{ companyName: 'Acme Corp', title: 'CEO' }]
738
+ * currentPositions: [{
739
+ * companyName: 'Acme Corp',
740
+ * companyUrnResolutionResult: { website: 'acme.com' }
741
+ * }]
648
742
  * };
649
743
  *
650
744
  * const result = await getEmailsForLinkedInContact(contact, {
@@ -656,164 +750,366 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
656
750
  * email: process.env.SMARTLEAD_EMAIL,
657
751
  * password: process.env.SMARTLEAD_PASSWORD,
658
752
  * },
753
+ * // companyDomain extracted automatically from currentPositions
659
754
  * });
660
755
  *
661
756
  * if (result.success) {
757
+ * console.log('Providers queried:', result.providersQueried);
662
758
  * console.log('Found emails:', result.emails);
663
759
  * // [
664
760
  * // { email: 'jim@acme.com', source: 'ldd', confidence: 95, type: 'business' },
665
- * // { email: 'jim@gmail.com', source: 'ldd', confidence: 70, type: 'personal' }
761
+ * // { email: 'j.demaio@acme.com', source: 'smartprospect', confidence: 85, type: 'business' },
666
762
  * // ]
667
763
  * }
668
764
  * ```
669
765
  */
670
- async function getEmailsForLinkedInContact(contact, config, options = {}) {
671
- const { skipLdd = false, skipSmartProspect = false, minMatchConfidence = 60, includeCompany = true, } = options;
766
+ async function getEmailsForLinkedInContact(contactOrLead, config, options = {}) {
767
+ // Normalize input - accept either LinkedInContact or raw SalesLeadSearchResult
768
+ const contact = normalizeToContact(contactOrLead);
769
+ const { skipLdd = false, skipSmartProspect = false, skipPatternGuessing = false, skipPaidProviders = false, minMatchConfidence = 60, paidProviderThreshold = 80, includeCompany = true, } = options;
672
770
  // Extract numeric ID from objectUrn
673
771
  const numericLinkedInId = (0, ldd_1.extractNumericLinkedInId)(contact.objectUrn);
772
+ // Extract company domain from contact (LinkedIn data - often missing)
773
+ const linkedInCompanyDomain = config.companyDomain || extractCompanyDomain(contact);
674
774
  const result = {
675
775
  success: false,
676
776
  emails: [],
677
777
  numericLinkedInId,
678
778
  providersQueried: [],
779
+ errors: [],
780
+ };
781
+ // Track seen emails to deduplicate
782
+ const seenEmails = new Set();
783
+ const addEmail = (emailResult) => {
784
+ const lower = emailResult.email.toLowerCase();
785
+ if (!seenEmails.has(lower)) {
786
+ seenEmails.add(lower);
787
+ result.emails.push(emailResult);
788
+ }
679
789
  };
790
+ // Track company domain discovered from SmartProspect (since LinkedIn doesn't provide it)
791
+ let discoveredCompanyDomain = null;
680
792
  // ==========================================================================
681
- // Step 1: Try LDD first (FREE)
793
+ // Phase 1: FREE providers in PARALLEL (LDD + SmartProspect)
682
794
  // ==========================================================================
795
+ const freeProviderPromises = [];
796
+ // LDD lookup (FREE)
683
797
  if (!skipLdd && config.ldd?.apiUrl && config.ldd?.apiToken) {
684
798
  result.providersQueried.push('ldd');
685
- try {
686
- const lddProvider = (0, ldd_1.createLddProvider)(config.ldd);
687
- const lddResult = await lddProvider({
688
- numericLinkedInId: numericLinkedInId || undefined,
689
- linkedinUsername: undefined, // Let LDD provider extract from contact if needed
690
- firstName: contact.firstName,
691
- lastName: contact.lastName,
692
- });
693
- if (lddResult && 'emails' in lddResult && lddResult.emails.length > 0) {
694
- // LDD found emails!
695
- for (const emailData of lddResult.emails) {
696
- result.emails.push({
697
- email: emailData.email,
698
- source: 'ldd',
699
- confidence: emailData.confidence ?? 90,
700
- type: emailData.metadata?.emailTypeClassified || 'unknown',
701
- verified: emailData.verified ?? true,
702
- metadata: emailData.metadata,
703
- });
799
+ freeProviderPromises.push(queryLdd(contact, config.ldd, numericLinkedInId, addEmail, result));
800
+ }
801
+ // SmartProspect lookup (FREE for FlexIQ)
802
+ // This also extracts company domain for pattern guessing
803
+ if (!skipSmartProspect && config.smartprospect) {
804
+ result.providersQueried.push('smartprospect');
805
+ freeProviderPromises.push(querySmartProspect(contact, config.smartprospect, minMatchConfidence, includeCompany, addEmail, result)
806
+ .then((domain) => {
807
+ if (domain) {
808
+ discoveredCompanyDomain = domain;
809
+ }
810
+ }));
811
+ }
812
+ // Wait for both free providers
813
+ await Promise.all(freeProviderPromises);
814
+ // Check if we have good enough results already
815
+ const bestConfidenceAfterFreeProviders = result.emails.length > 0
816
+ ? Math.max(...result.emails.map(e => e.confidence))
817
+ : 0;
818
+ // ==========================================================================
819
+ // Phase 2: Domain Discovery (if needed for pattern guessing)
820
+ // ==========================================================================
821
+ // Priority: LinkedIn contact data > SmartProspect > LinkedIn Company API
822
+ let companyDomain = linkedInCompanyDomain || discoveredCompanyDomain;
823
+ // If no domain yet and pattern guessing is enabled, try LinkedIn company lookup
824
+ if (!companyDomain &&
825
+ !skipPatternGuessing &&
826
+ !options.skipLinkedInCompanyLookup &&
827
+ config.linkedInCompanyLookup &&
828
+ bestConfidenceAfterFreeProviders < paidProviderThreshold) {
829
+ // Extract company ID from companyUrn
830
+ const companyUrn = contact.currentPositions?.[0]?.companyUrn;
831
+ if (companyUrn) {
832
+ const companyId = extractCompanyIdFromUrn(companyUrn);
833
+ if (companyId) {
834
+ result.providersQueried.push('linkedin');
835
+ try {
836
+ const company = await config.linkedInCompanyLookup(companyId);
837
+ if (company?.websiteUrl) {
838
+ companyDomain = extractDomainFromUrl(company.websiteUrl);
839
+ if (companyDomain) {
840
+ discoveredCompanyDomain = companyDomain;
841
+ }
842
+ }
843
+ }
844
+ catch (err) {
845
+ result.errors?.push(`LinkedIn: ${err instanceof Error ? err.message : 'Company lookup failed'}`);
704
846
  }
705
- result.success = true;
706
847
  }
707
848
  }
708
- catch (err) {
709
- // LDD failed, continue to SmartProspect
710
- result.error = `LDD error: ${err instanceof Error ? err.message : 'Unknown error'}`;
711
- }
849
+ }
850
+ // Store discovered domain in result for visibility
851
+ if (discoveredCompanyDomain && !linkedInCompanyDomain) {
852
+ result.discoveredCompanyDomain = discoveredCompanyDomain;
712
853
  }
713
854
  // ==========================================================================
714
- // Step 2: Fall back to SmartProspect if LDD didn't find emails (PAID)
855
+ // Phase 3: Email pattern guessing with MX verification (FREE)
715
856
  // ==========================================================================
716
- if (!result.success && !skipSmartProspect && config.smartprospect) {
717
- result.providersQueried.push('smartprospect');
718
- try {
719
- const client = (0, smartprospect_1.createSmartProspectClient)(config.smartprospect);
720
- if (!client) {
721
- result.error = 'Failed to create SmartProspect client';
722
- return result;
857
+ if (!skipPatternGuessing && companyDomain && bestConfidenceAfterFreeProviders < paidProviderThreshold) {
858
+ result.providersQueried.push('pattern');
859
+ await queryPatternGuessing(contact, companyDomain, addEmail, result);
860
+ }
861
+ // Recalculate best confidence
862
+ const finalBestConfidence = result.emails.length > 0
863
+ ? Math.max(...result.emails.map(e => e.confidence))
864
+ : 0;
865
+ // ==========================================================================
866
+ // Phase 3: PAID providers as last resort (Hunter/Apollo)
867
+ // ==========================================================================
868
+ if (!skipPaidProviders && finalBestConfidence < paidProviderThreshold) {
869
+ // Only use paid providers if we have low confidence or no results
870
+ // TODO: Implement Hunter and Apollo providers when needed
871
+ // For now, just mark that we would have queried them
872
+ if (config.hunter?.apiKey || config.apollo?.apiKey) {
873
+ // result.providersQueried.push('hunter');
874
+ // result.providersQueried.push('apollo');
875
+ // await queryPaidProviders(contact, config, addEmail, result);
876
+ }
877
+ }
878
+ // Sort emails by confidence (highest first), then by source priority
879
+ const sourcePriority = {
880
+ ldd: 0, // Highest priority - your own data
881
+ smartprospect: 1,
882
+ linkedin: 2, // LinkedIn company lookup (for domain discovery, doesn't provide emails)
883
+ pattern: 3,
884
+ hunter: 4,
885
+ apollo: 5,
886
+ };
887
+ result.emails.sort((a, b) => {
888
+ if (b.confidence !== a.confidence) {
889
+ return b.confidence - a.confidence;
890
+ }
891
+ return sourcePriority[a.source] - sourcePriority[b.source];
892
+ });
893
+ result.success = result.emails.length > 0;
894
+ // Clean up errors array if empty
895
+ if (result.errors && result.errors.length === 0) {
896
+ delete result.errors;
897
+ }
898
+ return result;
899
+ }
900
+ /**
901
+ * Extract company domain from LinkedIn contact
902
+ */
903
+ function extractCompanyDomain(contact) {
904
+ const pos = contact.currentPositions?.[0];
905
+ if (!pos)
906
+ return null;
907
+ // Try to get website from company resolution
908
+ const website = pos.companyUrnResolutionResult?.website;
909
+ if (website) {
910
+ return extractDomainFromUrl(website);
911
+ }
912
+ return null;
913
+ }
914
+ /**
915
+ * Extract domain from a URL string
916
+ */
917
+ function extractDomainFromUrl(url) {
918
+ if (!url)
919
+ return null;
920
+ try {
921
+ const fullUrl = url.startsWith('http') ? url : `https://${url}`;
922
+ return new URL(fullUrl).hostname.replace(/^www\./, '').toLowerCase();
923
+ }
924
+ catch {
925
+ // If URL parsing fails, try simple extraction
926
+ return url.replace(/^(https?:\/\/)?(www\.)?/, '').split('/')[0].toLowerCase() || null;
927
+ }
928
+ }
929
+ /**
930
+ * Extract company ID from a Sales Navigator company URN
931
+ * e.g., "urn:li:fs_salesCompany:108063139" → "108063139"
932
+ */
933
+ function extractCompanyIdFromUrn(companyUrn) {
934
+ if (!companyUrn)
935
+ return null;
936
+ // Handle various URN formats
937
+ const match = companyUrn.match(/(\d+)$/);
938
+ return match ? match[1] : null;
939
+ }
940
+ /**
941
+ * Query LDD provider
942
+ */
943
+ async function queryLdd(contact, lddConfig, numericLinkedInId, addEmail, result) {
944
+ try {
945
+ const lddProvider = (0, ldd_1.createLddProvider)(lddConfig);
946
+ const lddResult = await lddProvider({
947
+ numericLinkedInId: numericLinkedInId || undefined,
948
+ firstName: contact.firstName,
949
+ lastName: contact.lastName,
950
+ });
951
+ if (lddResult && 'emails' in lddResult && lddResult.emails.length > 0) {
952
+ for (const emailData of lddResult.emails) {
953
+ addEmail({
954
+ email: emailData.email,
955
+ source: 'ldd',
956
+ confidence: emailData.confidence ?? 90,
957
+ type: emailData.metadata?.emailTypeClassified || 'unknown',
958
+ verified: emailData.verified ?? true,
959
+ metadata: emailData.metadata,
960
+ });
723
961
  }
724
- // Build search filters
725
- const filters = buildSmartProspectFiltersFromLinkedIn(contact);
726
- const searchFilters = {
727
- firstName: filters.firstName,
728
- lastName: filters.lastName,
729
- limit: 25,
730
- };
962
+ }
963
+ }
964
+ catch (err) {
965
+ result.errors?.push(`LDD: ${err instanceof Error ? err.message : 'Unknown error'}`);
966
+ }
967
+ }
968
+ /**
969
+ * Query SmartProspect provider
970
+ * Returns the company domain if found (for pattern guessing fallback)
971
+ */
972
+ async function querySmartProspect(contact, smartProspectConfig, minMatchConfidence, includeCompany, addEmail, result) {
973
+ try {
974
+ const client = (0, smartprospect_1.createSmartProspectClient)(smartProspectConfig);
975
+ if (!client) {
976
+ result.errors?.push('SmartProspect: Failed to create client');
977
+ return null;
978
+ }
979
+ // Build search filters
980
+ const filters = buildSmartProspectFiltersFromLinkedIn(contact);
981
+ const searchFilters = {
982
+ firstName: filters.firstName,
983
+ lastName: filters.lastName,
984
+ limit: 25,
985
+ };
986
+ if (includeCompany && filters.companyName) {
987
+ searchFilters.companyName = filters.companyName;
988
+ }
989
+ // Search for matching contacts
990
+ let searchResponse = await client.search(searchFilters);
991
+ // Try broader search if no results
992
+ if (!searchResponse.success || searchResponse.data.list.length === 0) {
731
993
  if (includeCompany && filters.companyName) {
732
- searchFilters.companyName = filters.companyName;
733
- }
734
- // Search for matching contacts
735
- const searchResponse = await client.search(searchFilters);
736
- if (!searchResponse.success || searchResponse.data.list.length === 0) {
737
- // Try broader search without company
738
- if (includeCompany && filters.companyName) {
739
- const broaderResponse = await client.search({
740
- firstName: filters.firstName,
741
- lastName: filters.lastName,
742
- limit: 25,
743
- });
744
- if (broaderResponse.success && broaderResponse.data.list.length > 0) {
745
- searchResponse.data.list = broaderResponse.data.list;
746
- }
994
+ const broaderResponse = await client.search({
995
+ firstName: filters.firstName,
996
+ lastName: filters.lastName,
997
+ limit: 25,
998
+ });
999
+ if (broaderResponse.success && broaderResponse.data.list.length > 0) {
1000
+ searchResponse = broaderResponse;
747
1001
  }
748
1002
  }
749
- if (searchResponse.data.list.length === 0) {
750
- result.error = result.error
751
- ? `${result.error}; SmartProspect: No candidates found`
752
- : 'SmartProspect: No candidates found';
753
- return result;
754
- }
755
- // Find best match
756
- const matchResult = findBestMatch(contact, searchResponse.data.list, {
757
- minConfidence: minMatchConfidence,
758
- fuzzyNames: true,
759
- fuzzyCompany: true,
760
- });
761
- if (!matchResult) {
762
- result.error = result.error
763
- ? `${result.error}; SmartProspect: No match above ${minMatchConfidence}% confidence`
764
- : `SmartProspect: No match above ${minMatchConfidence}% confidence`;
765
- return result;
766
- }
767
- // Fetch email for matched contact (COSTS CREDITS)
768
- const fetchResponse = await client.fetch([matchResult.smartProspectContact.id]);
769
- if (fetchResponse.success && fetchResponse.data.list.length > 0) {
770
- const enrichedContact = fetchResponse.data.list[0];
771
- if (enrichedContact.email) {
772
- result.emails.push({
773
- email: enrichedContact.email,
774
- source: 'smartprospect',
775
- confidence: matchResult.confidence,
776
- type: 'business', // SmartProspect returns business emails
777
- verified: enrichedContact.verificationStatus === 'verified',
778
- deliverability: enrichedContact.emailDeliverability,
779
- metadata: {
780
- matchQuality: matchResult.quality,
781
- matchedFields: matchResult.matchedFields,
782
- smartProspectId: enrichedContact.id,
783
- company: enrichedContact.company?.name,
784
- title: enrichedContact.title,
785
- },
786
- });
787
- result.success = true;
788
- }
1003
+ }
1004
+ if (searchResponse.data.list.length === 0) {
1005
+ return null; // No candidates found, but not an error
1006
+ }
1007
+ // Find best match
1008
+ const matchResult = findBestMatch(contact, searchResponse.data.list, {
1009
+ minConfidence: minMatchConfidence,
1010
+ fuzzyNames: true,
1011
+ fuzzyCompany: true,
1012
+ });
1013
+ if (!matchResult) {
1014
+ return null; // No good match, but not an error
1015
+ }
1016
+ // Extract company domain from matched contact (SmartProspect provides this!)
1017
+ const companyWebsite = matchResult.smartProspectContact.company?.website;
1018
+ let discoveredDomain = null;
1019
+ if (companyWebsite) {
1020
+ // Clean up the domain (remove protocol, www, trailing slashes)
1021
+ discoveredDomain = companyWebsite
1022
+ .replace(/^(https?:\/\/)?(www\.)?/, '')
1023
+ .split('/')[0]
1024
+ .toLowerCase();
1025
+ }
1026
+ // Fetch email for matched contact
1027
+ const fetchResponse = await client.fetch([matchResult.smartProspectContact.id]);
1028
+ if (fetchResponse.success && fetchResponse.data.list.length > 0) {
1029
+ const enrichedContact = fetchResponse.data.list[0];
1030
+ if (enrichedContact.email) {
1031
+ addEmail({
1032
+ email: enrichedContact.email,
1033
+ source: 'smartprospect',
1034
+ confidence: matchResult.confidence,
1035
+ type: 'business',
1036
+ verified: enrichedContact.verificationStatus === 'verified',
1037
+ deliverability: enrichedContact.emailDeliverability,
1038
+ metadata: {
1039
+ matchQuality: matchResult.quality,
1040
+ matchedFields: matchResult.matchedFields,
1041
+ smartProspectId: enrichedContact.id,
1042
+ company: enrichedContact.company?.name,
1043
+ companyWebsite: companyWebsite,
1044
+ title: enrichedContact.title,
1045
+ },
1046
+ });
789
1047
  }
790
1048
  }
791
- catch (err) {
792
- result.error = result.error
793
- ? `${result.error}; SmartProspect error: ${err instanceof Error ? err.message : 'Unknown error'}`
794
- : `SmartProspect error: ${err instanceof Error ? err.message : 'Unknown error'}`;
1049
+ return discoveredDomain;
1050
+ }
1051
+ catch (err) {
1052
+ result.errors?.push(`SmartProspect: ${err instanceof Error ? err.message : 'Unknown error'}`);
1053
+ return null;
1054
+ }
1055
+ }
1056
+ /**
1057
+ * Query pattern guessing with MX verification
1058
+ */
1059
+ async function queryPatternGuessing(contact, companyDomain, addEmail, result) {
1060
+ try {
1061
+ // Import construct provider dynamically to avoid circular deps
1062
+ const { createConstructProvider } = await Promise.resolve().then(() => __importStar(require('./providers/construct')));
1063
+ const constructProvider = createConstructProvider({ maxAttempts: 6, timeoutMs: 3000 });
1064
+ const constructResult = await constructProvider({
1065
+ firstName: contact.firstName,
1066
+ lastName: contact.lastName,
1067
+ domain: companyDomain,
1068
+ });
1069
+ if (constructResult && 'emails' in constructResult && constructResult.emails.length > 0) {
1070
+ for (const emailData of constructResult.emails) {
1071
+ addEmail({
1072
+ email: emailData.email,
1073
+ source: 'pattern',
1074
+ confidence: emailData.confidence ?? 50,
1075
+ type: 'business',
1076
+ verified: emailData.verified ?? false,
1077
+ isCatchAll: emailData.isCatchAll,
1078
+ metadata: emailData.metadata,
1079
+ });
1080
+ }
795
1081
  }
796
1082
  }
797
- // Sort emails by confidence (highest first)
798
- result.emails.sort((a, b) => b.confidence - a.confidence);
799
- return result;
1083
+ catch (err) {
1084
+ result.errors?.push(`Pattern: ${err instanceof Error ? err.message : 'Unknown error'}`);
1085
+ }
800
1086
  }
801
1087
  /**
802
1088
  * Get emails for multiple LinkedIn contacts in batch
803
1089
  *
804
- * Processes contacts sequentially to avoid rate limits.
1090
+ * Processes contacts with controlled concurrency to balance speed and rate limits.
1091
+ * Accepts either LinkedInContact[] or SalesLeadSearchResult[] directly from Sales Nav search.
805
1092
  */
806
- async function getEmailsForLinkedInContactsBatch(contacts, config, options = {}) {
807
- const { delayMs = 200, onProgress, ...lookupOptions } = options;
808
- const results = [];
809
- for (let i = 0; i < contacts.length; i++) {
810
- const result = await getEmailsForLinkedInContact(contacts[i], config, lookupOptions);
811
- results.push(result);
812
- if (onProgress) {
813
- onProgress(i + 1, contacts.length, result);
814
- }
815
- // Delay between requests (except for last one)
816
- if (i < contacts.length - 1 && delayMs > 0) {
1093
+ async function getEmailsForLinkedInContactsBatch(contactsOrLeads, config, options = {}) {
1094
+ const { delayMs = 100, concurrency = 3, onProgress, ...lookupOptions } = options;
1095
+ const results = new Array(contactsOrLeads.length);
1096
+ let completed = 0;
1097
+ // Process in batches with concurrency limit
1098
+ for (let i = 0; i < contactsOrLeads.length; i += concurrency) {
1099
+ const batch = contactsOrLeads.slice(i, i + concurrency);
1100
+ const batchPromises = batch.map(async (contactOrLead, batchIndex) => {
1101
+ const result = await getEmailsForLinkedInContact(contactOrLead, config, lookupOptions);
1102
+ const globalIndex = i + batchIndex;
1103
+ results[globalIndex] = result;
1104
+ completed++;
1105
+ if (onProgress) {
1106
+ onProgress(completed, contactsOrLeads.length, result);
1107
+ }
1108
+ return result;
1109
+ });
1110
+ await Promise.all(batchPromises);
1111
+ // Delay between batches (except for last batch)
1112
+ if (i + concurrency < contactsOrLeads.length && delayMs > 0) {
817
1113
  await new Promise((r) => setTimeout(r, delayMs));
818
1114
  }
819
1115
  }
@@ -42,7 +42,7 @@ function parseCompany(raw) {
42
42
  data?.universalName ||
43
43
  r?.universalName),
44
44
  name: (miniCompany?.name || data?.name || r?.name),
45
- websiteUrl: (data?.websiteUrl || r?.websiteUrl),
45
+ websiteUrl: (data?.websiteUrl || data?.website || r?.websiteUrl || r?.website),
46
46
  description: (data?.description || r?.description),
47
47
  sizeLabel: (typeof data?.employeeCountRange === "string"
48
48
  ? data.employeeCountRange
package/dist/types.d.ts CHANGED
@@ -119,6 +119,9 @@ export interface LinkedInSpotlightBadge {
119
119
  }>;
120
120
  }
121
121
  export interface SalesLeadSearchResult {
122
+ firstName?: string;
123
+ lastName?: string;
124
+ fullName?: string;
122
125
  name?: string;
123
126
  headline?: string;
124
127
  imageUrl?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkedin-secret-sauce",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Private LinkedIn Sales Navigator client with automatic cookie management",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",