linkedin-secret-sauce 0.12.0 → 0.12.2
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 +50 -21
- package/dist/cosiall-client.d.ts +1 -1
- package/dist/cosiall-client.js +1 -1
- package/dist/enrichment/index.d.ts +3 -3
- package/dist/enrichment/index.js +19 -2
- package/dist/enrichment/matching.d.ts +29 -9
- package/dist/enrichment/matching.js +545 -142
- package/dist/enrichment/providers/bounceban.d.ts +82 -0
- package/dist/enrichment/providers/bounceban.js +447 -0
- package/dist/enrichment/providers/bouncer.d.ts +1 -1
- package/dist/enrichment/providers/bouncer.js +19 -21
- package/dist/enrichment/providers/construct.d.ts +1 -1
- package/dist/enrichment/providers/construct.js +22 -38
- package/dist/enrichment/providers/cosiall.d.ts +27 -0
- package/dist/enrichment/providers/cosiall.js +109 -0
- package/dist/enrichment/providers/dropcontact.d.ts +15 -9
- package/dist/enrichment/providers/dropcontact.js +188 -19
- package/dist/enrichment/providers/hunter.d.ts +8 -1
- package/dist/enrichment/providers/hunter.js +52 -28
- package/dist/enrichment/providers/index.d.ts +10 -7
- package/dist/enrichment/providers/index.js +12 -1
- package/dist/enrichment/providers/ldd.d.ts +1 -10
- package/dist/enrichment/providers/ldd.js +20 -97
- package/dist/enrichment/providers/smartprospect.js +28 -48
- package/dist/enrichment/providers/snovio.d.ts +1 -1
- package/dist/enrichment/providers/snovio.js +29 -31
- package/dist/enrichment/providers/trykitt.d.ts +63 -0
- package/dist/enrichment/providers/trykitt.js +210 -0
- package/dist/enrichment/types.d.ts +234 -17
- package/dist/enrichment/types.js +60 -48
- package/dist/enrichment/utils/candidate-parser.d.ts +107 -0
- package/dist/enrichment/utils/candidate-parser.js +173 -0
- package/dist/enrichment/utils/noop-provider.d.ts +39 -0
- package/dist/enrichment/utils/noop-provider.js +37 -0
- package/dist/enrichment/utils/rate-limiter.d.ts +103 -0
- package/dist/enrichment/utils/rate-limiter.js +204 -0
- package/dist/enrichment/utils/validation.d.ts +75 -3
- package/dist/enrichment/utils/validation.js +164 -11
- package/dist/linkedin-api.d.ts +40 -1
- package/dist/linkedin-api.js +160 -27
- package/dist/types.d.ts +50 -1
- package/dist/utils/lru-cache.d.ts +105 -0
- package/dist/utils/lru-cache.js +175 -0
- package/package.json +25 -26
|
@@ -70,8 +70,8 @@ const ldd_1 = require("./providers/ldd");
|
|
|
70
70
|
function salesLeadToContact(lead) {
|
|
71
71
|
// Use firstName/lastName directly from Sales Nav - they're already separate fields
|
|
72
72
|
// This preserves exact names as shown on LinkedIn (important for SmartProspect matching)
|
|
73
|
-
const firstName = lead.firstName ||
|
|
74
|
-
const lastName = lead.lastName ||
|
|
73
|
+
const firstName = lead.firstName || "";
|
|
74
|
+
const lastName = lead.lastName || "";
|
|
75
75
|
return {
|
|
76
76
|
objectUrn: lead.objectUrn,
|
|
77
77
|
entityUrn: lead.salesProfileUrn,
|
|
@@ -100,7 +100,7 @@ function salesLeadToContact(lead) {
|
|
|
100
100
|
*/
|
|
101
101
|
function isSalesLeadSearchResult(input) {
|
|
102
102
|
// SalesLeadSearchResult has 'name' but not 'firstName'
|
|
103
|
-
return
|
|
103
|
+
return "name" in input && !("firstName" in input);
|
|
104
104
|
}
|
|
105
105
|
/**
|
|
106
106
|
* Normalize input to LinkedInContact - accepts either format
|
|
@@ -119,8 +119,8 @@ function normalizeToContact(input) {
|
|
|
119
119
|
*/
|
|
120
120
|
function normalize(str) {
|
|
121
121
|
if (!str)
|
|
122
|
-
return
|
|
123
|
-
return str.toLowerCase().trim().replace(/\s+/g,
|
|
122
|
+
return "";
|
|
123
|
+
return str.toLowerCase().trim().replace(/\s+/g, " ");
|
|
124
124
|
}
|
|
125
125
|
/**
|
|
126
126
|
* Extract company name from LinkedIn position
|
|
@@ -128,15 +128,15 @@ function normalize(str) {
|
|
|
128
128
|
function getLinkedInCompany(contact) {
|
|
129
129
|
const pos = contact.currentPositions?.[0];
|
|
130
130
|
if (!pos)
|
|
131
|
-
return
|
|
132
|
-
return pos.companyUrnResolutionResult?.name || pos.companyName ||
|
|
131
|
+
return "";
|
|
132
|
+
return pos.companyUrnResolutionResult?.name || pos.companyName || "";
|
|
133
133
|
}
|
|
134
134
|
/**
|
|
135
135
|
* Extract job title from LinkedIn position
|
|
136
136
|
*/
|
|
137
137
|
function getLinkedInTitle(contact) {
|
|
138
138
|
const pos = contact.currentPositions?.[0];
|
|
139
|
-
return pos?.title ||
|
|
139
|
+
return pos?.title || "";
|
|
140
140
|
}
|
|
141
141
|
/**
|
|
142
142
|
* Extract location parts from LinkedIn geoRegion
|
|
@@ -144,8 +144,8 @@ function getLinkedInTitle(contact) {
|
|
|
144
144
|
*/
|
|
145
145
|
function parseLinkedInLocation(geoRegion) {
|
|
146
146
|
if (!geoRegion)
|
|
147
|
-
return { city:
|
|
148
|
-
const parts = geoRegion.split(
|
|
147
|
+
return { city: "", state: "", country: "" };
|
|
148
|
+
const parts = geoRegion.split(",").map((p) => p.trim());
|
|
149
149
|
if (parts.length >= 3) {
|
|
150
150
|
return {
|
|
151
151
|
city: parts[0],
|
|
@@ -156,15 +156,15 @@ function parseLinkedInLocation(geoRegion) {
|
|
|
156
156
|
else if (parts.length === 2) {
|
|
157
157
|
return {
|
|
158
158
|
city: parts[0],
|
|
159
|
-
state:
|
|
159
|
+
state: "",
|
|
160
160
|
country: parts[1],
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
163
|
else {
|
|
164
164
|
return {
|
|
165
|
-
city:
|
|
166
|
-
state:
|
|
167
|
-
country: parts[0] ||
|
|
165
|
+
city: "",
|
|
166
|
+
state: "",
|
|
167
|
+
country: parts[0] || "",
|
|
168
168
|
};
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -209,7 +209,7 @@ function stringSimilarity(a, b) {
|
|
|
209
209
|
* Check if strings match exactly (normalized)
|
|
210
210
|
*/
|
|
211
211
|
function exactMatch(a, b) {
|
|
212
|
-
return normalize(a) === normalize(b) && normalize(a) !==
|
|
212
|
+
return normalize(a) === normalize(b) && normalize(a) !== "";
|
|
213
213
|
}
|
|
214
214
|
/**
|
|
215
215
|
* Check if strings match with fuzzy tolerance
|
|
@@ -245,50 +245,52 @@ function calculateMatchConfidence(linkedin, smartprospect, options = {}) {
|
|
|
245
245
|
// First name match
|
|
246
246
|
if (exactMatch(linkedin.firstName, smartprospect.firstName)) {
|
|
247
247
|
score += 20;
|
|
248
|
-
matchedFields.push(
|
|
248
|
+
matchedFields.push("firstName:exact");
|
|
249
249
|
}
|
|
250
|
-
else if (fuzzyNames &&
|
|
250
|
+
else if (fuzzyNames &&
|
|
251
|
+
fuzzyMatch(linkedin.firstName, smartprospect.firstName)) {
|
|
251
252
|
score += 15;
|
|
252
|
-
matchedFields.push(
|
|
253
|
+
matchedFields.push("firstName:fuzzy");
|
|
253
254
|
}
|
|
254
255
|
// Last name match
|
|
255
256
|
if (exactMatch(linkedin.lastName, smartprospect.lastName)) {
|
|
256
257
|
score += 20;
|
|
257
|
-
matchedFields.push(
|
|
258
|
+
matchedFields.push("lastName:exact");
|
|
258
259
|
}
|
|
259
|
-
else if (fuzzyNames &&
|
|
260
|
+
else if (fuzzyNames &&
|
|
261
|
+
fuzzyMatch(linkedin.lastName, smartprospect.lastName)) {
|
|
260
262
|
score += 15;
|
|
261
|
-
matchedFields.push(
|
|
263
|
+
matchedFields.push("lastName:fuzzy");
|
|
262
264
|
}
|
|
263
265
|
// === Company matching (up to 30 points) ===
|
|
264
266
|
const liCompany = getLinkedInCompany(linkedin);
|
|
265
|
-
const spCompany = smartprospect.company?.name ||
|
|
267
|
+
const spCompany = smartprospect.company?.name || "";
|
|
266
268
|
if (exactMatch(liCompany, spCompany)) {
|
|
267
269
|
score += 30;
|
|
268
|
-
matchedFields.push(
|
|
270
|
+
matchedFields.push("company:exact");
|
|
269
271
|
}
|
|
270
272
|
else if (fuzzyCompany && fuzzyMatch(liCompany, spCompany, 0.8)) {
|
|
271
273
|
score += 25;
|
|
272
|
-
matchedFields.push(
|
|
274
|
+
matchedFields.push("company:fuzzy");
|
|
273
275
|
}
|
|
274
276
|
else if (containsMatch(liCompany, spCompany)) {
|
|
275
277
|
score += 15;
|
|
276
|
-
matchedFields.push(
|
|
278
|
+
matchedFields.push("company:contains");
|
|
277
279
|
}
|
|
278
280
|
// === Title matching (up to 15 points) ===
|
|
279
281
|
const liTitle = getLinkedInTitle(linkedin);
|
|
280
|
-
const spTitle = smartprospect.title ||
|
|
282
|
+
const spTitle = smartprospect.title || "";
|
|
281
283
|
if (exactMatch(liTitle, spTitle)) {
|
|
282
284
|
score += 15;
|
|
283
|
-
matchedFields.push(
|
|
285
|
+
matchedFields.push("title:exact");
|
|
284
286
|
}
|
|
285
287
|
else if (fuzzyMatch(liTitle, spTitle, 0.75)) {
|
|
286
288
|
score += 12;
|
|
287
|
-
matchedFields.push(
|
|
289
|
+
matchedFields.push("title:fuzzy");
|
|
288
290
|
}
|
|
289
291
|
else if (containsMatch(liTitle, spTitle)) {
|
|
290
292
|
score += 8;
|
|
291
|
-
matchedFields.push(
|
|
293
|
+
matchedFields.push("title:contains");
|
|
292
294
|
}
|
|
293
295
|
// === Location matching (up to 15 points) ===
|
|
294
296
|
const liLocation = parseLinkedInLocation(linkedin.geoRegion);
|
|
@@ -298,17 +300,17 @@ function calculateMatchConfidence(linkedin, smartprospect, options = {}) {
|
|
|
298
300
|
// Country match
|
|
299
301
|
if (spCountry && exactMatch(liLocation.country, smartprospect.country)) {
|
|
300
302
|
score += 5;
|
|
301
|
-
matchedFields.push(
|
|
303
|
+
matchedFields.push("country:exact");
|
|
302
304
|
}
|
|
303
305
|
// State match
|
|
304
306
|
if (spState && exactMatch(liLocation.state, smartprospect.state)) {
|
|
305
307
|
score += 5;
|
|
306
|
-
matchedFields.push(
|
|
308
|
+
matchedFields.push("state:exact");
|
|
307
309
|
}
|
|
308
310
|
// City match
|
|
309
311
|
if (spCity && exactMatch(liLocation.city, smartprospect.city)) {
|
|
310
312
|
score += 5;
|
|
311
|
-
matchedFields.push(
|
|
313
|
+
matchedFields.push("city:exact");
|
|
312
314
|
}
|
|
313
315
|
return { confidence: Math.min(100, score), matchedFields };
|
|
314
316
|
}
|
|
@@ -316,22 +318,22 @@ function calculateMatchConfidence(linkedin, smartprospect, options = {}) {
|
|
|
316
318
|
* Classify match quality based on confidence and matched fields
|
|
317
319
|
*/
|
|
318
320
|
function classifyMatchQuality(confidence, matchedFields) {
|
|
319
|
-
const hasNameMatch = matchedFields.some((f) => f.startsWith(
|
|
320
|
-
matchedFields.some((f) => f.startsWith(
|
|
321
|
-
const hasCompanyMatch = matchedFields.some((f) => f.startsWith(
|
|
321
|
+
const hasNameMatch = matchedFields.some((f) => f.startsWith("firstName:exact")) &&
|
|
322
|
+
matchedFields.some((f) => f.startsWith("lastName:exact"));
|
|
323
|
+
const hasCompanyMatch = matchedFields.some((f) => f.startsWith("company:"));
|
|
322
324
|
if (confidence >= 85 && hasNameMatch && hasCompanyMatch) {
|
|
323
|
-
return
|
|
325
|
+
return "exact";
|
|
324
326
|
}
|
|
325
327
|
else if (confidence >= 70 && hasNameMatch) {
|
|
326
|
-
return
|
|
328
|
+
return "high";
|
|
327
329
|
}
|
|
328
330
|
else if (confidence >= 50) {
|
|
329
|
-
return
|
|
331
|
+
return "medium";
|
|
330
332
|
}
|
|
331
333
|
else if (confidence >= 30) {
|
|
332
|
-
return
|
|
334
|
+
return "low";
|
|
333
335
|
}
|
|
334
|
-
return
|
|
336
|
+
return "none";
|
|
335
337
|
}
|
|
336
338
|
/**
|
|
337
339
|
* Find the best matching SmartProspect contact for a LinkedIn contact
|
|
@@ -399,8 +401,8 @@ function parseLinkedInSearchResponse(elements) {
|
|
|
399
401
|
return elements.map((el) => ({
|
|
400
402
|
objectUrn: el.objectUrn,
|
|
401
403
|
entityUrn: el.entityUrn,
|
|
402
|
-
firstName: el.firstName ||
|
|
403
|
-
lastName: el.lastName ||
|
|
404
|
+
firstName: el.firstName || "",
|
|
405
|
+
lastName: el.lastName || "",
|
|
404
406
|
fullName: el.fullName || undefined,
|
|
405
407
|
geoRegion: el.geoRegion || undefined,
|
|
406
408
|
currentPositions: el.currentPositions,
|
|
@@ -457,7 +459,7 @@ async function enrichLinkedInContact(linkedInContact, smartProspectConfig, optio
|
|
|
457
459
|
numericLinkedInId,
|
|
458
460
|
matchedContact: null,
|
|
459
461
|
matchConfidence: 0,
|
|
460
|
-
matchQuality:
|
|
462
|
+
matchQuality: "none",
|
|
461
463
|
matchedFields: [],
|
|
462
464
|
email: null,
|
|
463
465
|
emailDeliverability: 0,
|
|
@@ -468,7 +470,7 @@ async function enrichLinkedInContact(linkedInContact, smartProspectConfig, optio
|
|
|
468
470
|
// Create SmartProspect client
|
|
469
471
|
const client = (0, smartprospect_1.createSmartProspectClient)(smartProspectConfig);
|
|
470
472
|
if (!client) {
|
|
471
|
-
result.error =
|
|
473
|
+
result.error = "Failed to create SmartProspect client - check credentials";
|
|
472
474
|
return result;
|
|
473
475
|
}
|
|
474
476
|
try {
|
|
@@ -509,12 +511,16 @@ async function enrichLinkedInContact(linkedInContact, smartProspectConfig, optio
|
|
|
509
511
|
}
|
|
510
512
|
}
|
|
511
513
|
if (result.allCandidates.length === 0) {
|
|
512
|
-
result.error =
|
|
514
|
+
result.error = "No candidates found in SmartProspect";
|
|
513
515
|
return result;
|
|
514
516
|
}
|
|
515
517
|
}
|
|
516
518
|
// Step 2: Find best match using intelligent matching
|
|
517
|
-
const matchResult = findBestMatch(linkedInContact, result.allCandidates, {
|
|
519
|
+
const matchResult = findBestMatch(linkedInContact, result.allCandidates, {
|
|
520
|
+
minConfidence,
|
|
521
|
+
fuzzyNames,
|
|
522
|
+
fuzzyCompany,
|
|
523
|
+
});
|
|
518
524
|
if (!matchResult) {
|
|
519
525
|
result.error = `No match above ${minConfidence}% confidence threshold`;
|
|
520
526
|
return result;
|
|
@@ -525,7 +531,9 @@ async function enrichLinkedInContact(linkedInContact, smartProspectConfig, optio
|
|
|
525
531
|
result.matchedFields = matchResult.matchedFields;
|
|
526
532
|
// Step 3: Fetch email if auto-fetch enabled and good match (COSTS CREDITS)
|
|
527
533
|
if (autoFetch && matchResult.confidence >= minConfidence) {
|
|
528
|
-
const fetchResponse = await client.fetch([
|
|
534
|
+
const fetchResponse = await client.fetch([
|
|
535
|
+
matchResult.smartProspectContact.id,
|
|
536
|
+
]);
|
|
529
537
|
if (fetchResponse.success && fetchResponse.data.list.length > 0) {
|
|
530
538
|
const enrichedContact = fetchResponse.data.list[0];
|
|
531
539
|
result.email = enrichedContact.email || null;
|
|
@@ -539,7 +547,7 @@ async function enrichLinkedInContact(linkedInContact, smartProspectConfig, optio
|
|
|
539
547
|
return result;
|
|
540
548
|
}
|
|
541
549
|
catch (err) {
|
|
542
|
-
result.error = err instanceof Error ? err.message :
|
|
550
|
+
result.error = err instanceof Error ? err.message : "Unknown error";
|
|
543
551
|
return result;
|
|
544
552
|
}
|
|
545
553
|
}
|
|
@@ -604,7 +612,7 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
|
|
|
604
612
|
numericLinkedInId,
|
|
605
613
|
matchedContact: null,
|
|
606
614
|
matchConfidence: 0,
|
|
607
|
-
matchQuality:
|
|
615
|
+
matchQuality: "none",
|
|
608
616
|
matchedFields: [],
|
|
609
617
|
email: null,
|
|
610
618
|
emailDeliverability: 0,
|
|
@@ -634,7 +642,9 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
|
|
|
634
642
|
result.allCandidates = searchResponse.data.list;
|
|
635
643
|
result.totalCandidatesFound = searchResponse.data.total_count;
|
|
636
644
|
// Broader search fallback
|
|
637
|
-
if (searchResponse.data.list.length === 0 &&
|
|
645
|
+
if (searchResponse.data.list.length === 0 &&
|
|
646
|
+
includeCompany &&
|
|
647
|
+
filters.companyName) {
|
|
638
648
|
const broaderResponse = await client.search({
|
|
639
649
|
firstName: filters.firstName,
|
|
640
650
|
lastName: filters.lastName,
|
|
@@ -646,7 +656,7 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
|
|
|
646
656
|
}
|
|
647
657
|
}
|
|
648
658
|
if (result.allCandidates.length === 0) {
|
|
649
|
-
result.error =
|
|
659
|
+
result.error = "No candidates found in SmartProspect";
|
|
650
660
|
return result;
|
|
651
661
|
}
|
|
652
662
|
const matchResult = findBestMatch(contact, result.allCandidates, {
|
|
@@ -663,12 +673,16 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
|
|
|
663
673
|
result.matchQuality = matchResult.quality;
|
|
664
674
|
result.matchedFields = matchResult.matchedFields;
|
|
665
675
|
if (autoFetch && matchResult.confidence >= minConfidence) {
|
|
666
|
-
const fetchResponse = await client.fetch([
|
|
676
|
+
const fetchResponse = await client.fetch([
|
|
677
|
+
matchResult.smartProspectContact.id,
|
|
678
|
+
]);
|
|
667
679
|
if (fetchResponse.success && fetchResponse.data.list.length > 0) {
|
|
668
680
|
const enrichedContact = fetchResponse.data.list[0];
|
|
669
681
|
result.email = enrichedContact.email || null;
|
|
670
|
-
result.emailDeliverability =
|
|
671
|
-
|
|
682
|
+
result.emailDeliverability =
|
|
683
|
+
enrichedContact.emailDeliverability || 0;
|
|
684
|
+
result.verificationStatus =
|
|
685
|
+
enrichedContact.verificationStatus || null;
|
|
672
686
|
result.matchedContact = enrichedContact;
|
|
673
687
|
}
|
|
674
688
|
}
|
|
@@ -676,7 +690,7 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
|
|
|
676
690
|
return result;
|
|
677
691
|
}
|
|
678
692
|
catch (err) {
|
|
679
|
-
result.error = err instanceof Error ? err.message :
|
|
693
|
+
result.error = err instanceof Error ? err.message : "Unknown error";
|
|
680
694
|
return result;
|
|
681
695
|
}
|
|
682
696
|
},
|
|
@@ -766,7 +780,7 @@ function createLinkedInEnricher(smartProspectConfig, defaultOptions = {}) {
|
|
|
766
780
|
async function getEmailsForLinkedInContact(contactOrLead, config, options = {}) {
|
|
767
781
|
// Normalize input - accept either LinkedInContact or raw SalesLeadSearchResult
|
|
768
782
|
const contact = normalizeToContact(contactOrLead);
|
|
769
|
-
const { skipLdd = false, skipSmartProspect = false, skipPatternGuessing = false, skipPaidProviders = false, minMatchConfidence = 60, paidProviderThreshold = 80, includeCompany = true, } = options;
|
|
783
|
+
const { skipLdd = false, skipSmartProspect = false, skipCosiall = false, skipTryKitt = false, skipPatternGuessing = false, skipBounceBan = false, skipPaidProviders = false, minMatchConfidence = 60, paidProviderThreshold = 80, includeCompany = true, } = options;
|
|
770
784
|
// Extract numeric ID from objectUrn
|
|
771
785
|
const numericLinkedInId = (0, ldd_1.extractNumericLinkedInId)(contact.objectUrn);
|
|
772
786
|
// Extract company domain from contact (LinkedIn data - often missing)
|
|
@@ -778,113 +792,220 @@ async function getEmailsForLinkedInContact(contactOrLead, config, options = {})
|
|
|
778
792
|
providersQueried: [],
|
|
779
793
|
errors: [],
|
|
780
794
|
};
|
|
781
|
-
// Track
|
|
782
|
-
const
|
|
795
|
+
// Track all emails with their sources for confidence boosting
|
|
796
|
+
const emailsByAddress = new Map();
|
|
783
797
|
const addEmail = (emailResult) => {
|
|
784
798
|
const lower = emailResult.email.toLowerCase();
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
799
|
+
const existing = emailsByAddress.get(lower);
|
|
800
|
+
if (existing) {
|
|
801
|
+
// Email already found from another source - track for confidence boosting
|
|
802
|
+
existing.results.push(emailResult);
|
|
803
|
+
existing.sources.add(emailResult.source);
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
emailsByAddress.set(lower, {
|
|
807
|
+
results: [emailResult],
|
|
808
|
+
sources: new Set([emailResult.source]),
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
/**
|
|
813
|
+
* Merge emails and boost confidence when found from multiple sources
|
|
814
|
+
* - 2 sources: boost to at least 95% confidence
|
|
815
|
+
* - 3+ sources: boost to 100% confidence
|
|
816
|
+
*/
|
|
817
|
+
const mergeAndBoostEmails = () => {
|
|
818
|
+
const mergedEmails = [];
|
|
819
|
+
for (const [_email, { results, sources }] of emailsByAddress) {
|
|
820
|
+
// Find the best result (highest confidence)
|
|
821
|
+
const bestResult = results.reduce((best, current) => current.confidence > best.confidence ? current : best);
|
|
822
|
+
// Boost confidence based on number of sources
|
|
823
|
+
let boostedConfidence = bestResult.confidence;
|
|
824
|
+
if (sources.size >= 3) {
|
|
825
|
+
boostedConfidence = 100;
|
|
826
|
+
}
|
|
827
|
+
else if (sources.size === 2) {
|
|
828
|
+
boostedConfidence = Math.max(boostedConfidence, 95);
|
|
829
|
+
}
|
|
830
|
+
// Create merged result
|
|
831
|
+
const mergedResult = {
|
|
832
|
+
...bestResult,
|
|
833
|
+
confidence: boostedConfidence,
|
|
834
|
+
metadata: {
|
|
835
|
+
...bestResult.metadata,
|
|
836
|
+
foundBySources: Array.from(sources),
|
|
837
|
+
sourceCount: sources.size,
|
|
838
|
+
confidenceBoosted: sources.size > 1,
|
|
839
|
+
originalConfidence: bestResult.confidence,
|
|
840
|
+
},
|
|
841
|
+
};
|
|
842
|
+
// If verified by any source, mark as verified
|
|
843
|
+
if (results.some((r) => r.verified)) {
|
|
844
|
+
mergedResult.verified = true;
|
|
845
|
+
}
|
|
846
|
+
mergedEmails.push(mergedResult);
|
|
788
847
|
}
|
|
848
|
+
return mergedEmails;
|
|
789
849
|
};
|
|
790
850
|
// Track company domain discovered from SmartProspect (since LinkedIn doesn't provide it)
|
|
791
851
|
let discoveredCompanyDomain = null;
|
|
792
852
|
// ==========================================================================
|
|
793
|
-
// Phase 1: FREE providers in PARALLEL (
|
|
853
|
+
// Phase 1: FREE database providers in PARALLEL (no LinkedIn API calls)
|
|
854
|
+
// LDD + SmartProspect + Cosiall - these are your own databases or already paid
|
|
794
855
|
// ==========================================================================
|
|
795
856
|
const freeProviderPromises = [];
|
|
796
|
-
// LDD lookup (FREE)
|
|
857
|
+
// LDD lookup (FREE - your ~500M database)
|
|
797
858
|
if (!skipLdd && config.ldd?.apiUrl && config.ldd?.apiToken) {
|
|
798
|
-
result.providersQueried.push(
|
|
859
|
+
result.providersQueried.push("ldd");
|
|
799
860
|
freeProviderPromises.push(queryLdd(contact, config.ldd, numericLinkedInId, addEmail, result));
|
|
800
861
|
}
|
|
801
|
-
// SmartProspect lookup (FREE for FlexIQ)
|
|
802
|
-
// This also extracts company domain for pattern guessing
|
|
862
|
+
// SmartProspect lookup (FREE for FlexIQ - already paying monthly)
|
|
863
|
+
// This also extracts company domain for pattern guessing (no extra API call)
|
|
803
864
|
if (!skipSmartProspect && config.smartprospect) {
|
|
804
|
-
result.providersQueried.push(
|
|
805
|
-
freeProviderPromises.push(querySmartProspect(contact, config.smartprospect, minMatchConfidence, includeCompany, addEmail, result)
|
|
806
|
-
.then((domain) => {
|
|
865
|
+
result.providersQueried.push("smartprospect");
|
|
866
|
+
freeProviderPromises.push(querySmartProspect(contact, config.smartprospect, minMatchConfidence, includeCompany, addEmail, result).then((domain) => {
|
|
807
867
|
if (domain) {
|
|
808
868
|
discoveredCompanyDomain = domain;
|
|
809
869
|
}
|
|
810
870
|
}));
|
|
811
871
|
}
|
|
812
|
-
//
|
|
872
|
+
// Cosiall Profile Emails lookup (FREE - your database)
|
|
873
|
+
if (!skipCosiall && config.cosiall?.enabled !== false) {
|
|
874
|
+
result.providersQueried.push("cosiall");
|
|
875
|
+
freeProviderPromises.push(queryCosiall(contact, addEmail, result));
|
|
876
|
+
}
|
|
877
|
+
// TryKitt.ai lookup (FREE for individuals - unlimited)
|
|
878
|
+
// AI-powered email finder with enterprise identity server catch-all verification
|
|
879
|
+
if (!skipTryKitt && config.trykitt?.apiKey) {
|
|
880
|
+
result.providersQueried.push("trykitt");
|
|
881
|
+
freeProviderPromises.push(queryTryKitt(contact, config.trykitt, linkedInCompanyDomain || discoveredCompanyDomain, addEmail, result));
|
|
882
|
+
}
|
|
883
|
+
// Wait for all Phase 1 free providers
|
|
813
884
|
await Promise.all(freeProviderPromises);
|
|
814
|
-
//
|
|
815
|
-
|
|
816
|
-
|
|
885
|
+
// Merge emails and calculate best confidence after Phase 1
|
|
886
|
+
result.emails = mergeAndBoostEmails();
|
|
887
|
+
const phase1BestConfidence = result.emails.length > 0
|
|
888
|
+
? Math.max(...result.emails.map((e) => e.confidence))
|
|
817
889
|
: 0;
|
|
818
890
|
// ==========================================================================
|
|
819
|
-
// Phase 2: Domain Discovery
|
|
891
|
+
// Phase 2: Domain Discovery + Pattern Guessing
|
|
892
|
+
// ONLY if Phase 1 confidence < threshold (to minimize LinkedIn API calls)
|
|
820
893
|
// ==========================================================================
|
|
821
|
-
// Priority: LinkedIn contact data > SmartProspect > LinkedIn Company API
|
|
822
894
|
let companyDomain = linkedInCompanyDomain || discoveredCompanyDomain;
|
|
823
|
-
//
|
|
824
|
-
if (
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
895
|
+
// Skip Phase 2 entirely if we already have high confidence emails
|
|
896
|
+
if (phase1BestConfidence < paidProviderThreshold) {
|
|
897
|
+
// Try LinkedIn Company Lookup for domain discovery (EXPENSIVE - uses LinkedIn API)
|
|
898
|
+
// Only if we don't have a domain yet
|
|
899
|
+
if (!companyDomain &&
|
|
900
|
+
!skipPatternGuessing &&
|
|
901
|
+
!options.skipLinkedInCompanyLookup &&
|
|
902
|
+
config.linkedInCompanyLookup) {
|
|
903
|
+
const companyUrn = contact.currentPositions?.[0]?.companyUrn;
|
|
904
|
+
if (companyUrn) {
|
|
905
|
+
const companyId = extractCompanyIdFromUrn(companyUrn);
|
|
906
|
+
if (companyId) {
|
|
907
|
+
result.providersQueried.push("linkedin");
|
|
908
|
+
try {
|
|
909
|
+
const company = await config.linkedInCompanyLookup(companyId);
|
|
910
|
+
if (company?.websiteUrl) {
|
|
911
|
+
companyDomain = extractDomainFromUrl(company.websiteUrl);
|
|
912
|
+
if (companyDomain) {
|
|
913
|
+
discoveredCompanyDomain = companyDomain;
|
|
914
|
+
}
|
|
841
915
|
}
|
|
842
916
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
917
|
+
catch (err) {
|
|
918
|
+
result.errors?.push(`LinkedIn: ${err instanceof Error ? err.message : "Company lookup failed"}`);
|
|
919
|
+
}
|
|
846
920
|
}
|
|
847
921
|
}
|
|
848
922
|
}
|
|
923
|
+
// Store discovered domain in result for visibility
|
|
924
|
+
if (discoveredCompanyDomain && !linkedInCompanyDomain) {
|
|
925
|
+
result.discoveredCompanyDomain = discoveredCompanyDomain;
|
|
926
|
+
}
|
|
927
|
+
// Run pattern guessing if we have a domain (from LinkedIn contact, SmartProspect, or LinkedIn API)
|
|
928
|
+
if (!skipPatternGuessing && companyDomain) {
|
|
929
|
+
result.providersQueried.push("pattern");
|
|
930
|
+
await queryPatternGuessing(contact, companyDomain, addEmail, result);
|
|
931
|
+
// Re-merge after pattern guessing
|
|
932
|
+
result.emails = mergeAndBoostEmails();
|
|
933
|
+
}
|
|
934
|
+
// BounceBan catch-all verification (FREE single emails)
|
|
935
|
+
// Use BounceBan to verify pattern-guessed emails, especially on catch-all domains
|
|
936
|
+
if (!skipBounceBan &&
|
|
937
|
+
config.bounceban?.apiKey &&
|
|
938
|
+
result.emails.length > 0) {
|
|
939
|
+
// Only verify emails that are from pattern guessing or have low confidence
|
|
940
|
+
const emailsToVerify = result.emails
|
|
941
|
+
.filter((e) => e.source === "pattern" || (e.confidence < 80 && !e.verified))
|
|
942
|
+
.slice(0, 3); // Verify top 3 to save credits
|
|
943
|
+
if (emailsToVerify.length > 0) {
|
|
944
|
+
result.providersQueried.push("bounceban");
|
|
945
|
+
await queryBounceBan(emailsToVerify, config.bounceban, addEmail, result);
|
|
946
|
+
// Re-merge after BounceBan verification
|
|
947
|
+
result.emails = mergeAndBoostEmails();
|
|
948
|
+
}
|
|
949
|
+
}
|
|
849
950
|
}
|
|
850
|
-
//
|
|
851
|
-
if (discoveredCompanyDomain && !linkedInCompanyDomain) {
|
|
852
|
-
result.discoveredCompanyDomain = discoveredCompanyDomain;
|
|
853
|
-
}
|
|
854
|
-
// ==========================================================================
|
|
855
|
-
// Phase 3: Email pattern guessing with MX verification (FREE)
|
|
856
|
-
// ==========================================================================
|
|
857
|
-
if (!skipPatternGuessing && companyDomain && bestConfidenceAfterFreeProviders < paidProviderThreshold) {
|
|
858
|
-
result.providersQueried.push('pattern');
|
|
859
|
-
await queryPatternGuessing(contact, companyDomain, addEmail, result);
|
|
860
|
-
}
|
|
861
|
-
// Recalculate best confidence
|
|
951
|
+
// Calculate final best confidence
|
|
862
952
|
const finalBestConfidence = result.emails.length > 0
|
|
863
|
-
? Math.max(...result.emails.map(e => e.confidence))
|
|
953
|
+
? Math.max(...result.emails.map((e) => e.confidence))
|
|
864
954
|
: 0;
|
|
865
955
|
// ==========================================================================
|
|
866
956
|
// Phase 3: PAID providers as last resort (Hunter/Bouncer/Snovio)
|
|
867
957
|
// ==========================================================================
|
|
868
958
|
if (!skipPaidProviders && finalBestConfidence < paidProviderThreshold) {
|
|
869
959
|
// Only use paid providers if we have low confidence or no results
|
|
870
|
-
//
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
|
|
960
|
+
// Hunter.io - Email Finder ($0.005/lookup)
|
|
961
|
+
if (config.hunter?.apiKey) {
|
|
962
|
+
result.providersQueried.push("hunter");
|
|
963
|
+
// Extract linkedin handle from contact if available
|
|
964
|
+
// Note: For best results, caller should fetch profile with getSalesNavigatorProfileFull
|
|
965
|
+
// and extract handle using extractLinkedInHandle(profile.flagshipProfileUrl)
|
|
966
|
+
let linkedinHandle = null;
|
|
967
|
+
// Try to extract from entityUrn or any linkedin URL fields
|
|
968
|
+
// (These won't typically have the vanity, but check anyway)
|
|
969
|
+
const contactAny = contact;
|
|
970
|
+
if (contactAny.linkedinHandle) {
|
|
971
|
+
linkedinHandle = String(contactAny.linkedinHandle);
|
|
972
|
+
}
|
|
973
|
+
else if (contactAny.flagshipProfileUrl) {
|
|
974
|
+
// Extract handle from flagship URL
|
|
975
|
+
const match = String(contactAny.flagshipProfileUrl).match(/linkedin\.com\/in\/([^\/\?]+)/i);
|
|
976
|
+
if (match)
|
|
977
|
+
linkedinHandle = match[1];
|
|
978
|
+
}
|
|
979
|
+
await queryHunter(contact, config.hunter, companyDomain || null, linkedinHandle, addEmail, result);
|
|
980
|
+
// Re-merge after Hunter
|
|
981
|
+
result.emails = mergeAndBoostEmails();
|
|
877
982
|
}
|
|
983
|
+
// Snovio - Email Finder ($0.02/lookup) - best for catch-all domains
|
|
984
|
+
if (config.snovio?.userId && config.snovio?.apiSecret && companyDomain) {
|
|
985
|
+
result.providersQueried.push("snovio");
|
|
986
|
+
await querySnovio(contact, config.snovio, companyDomain, addEmail, result);
|
|
987
|
+
// Re-merge after Snovio
|
|
988
|
+
result.emails = mergeAndBoostEmails();
|
|
989
|
+
}
|
|
990
|
+
// Note: Bouncer is a VERIFICATION provider (verifies existing emails).
|
|
991
|
+
// It's automatically used by the construct provider for SMTP verification.
|
|
992
|
+
// No need to call it separately here since pattern guessing already uses
|
|
993
|
+
// the built-in mx verification which is similar to Bouncer.
|
|
878
994
|
}
|
|
995
|
+
// Final merge (in case paid providers added emails) and sort
|
|
996
|
+
result.emails = mergeAndBoostEmails();
|
|
879
997
|
// Sort emails by confidence (highest first), then by source priority
|
|
880
998
|
const sourcePriority = {
|
|
881
999
|
ldd: 0, // Highest priority - your own data
|
|
882
1000
|
smartprospect: 1,
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1001
|
+
cosiall: 2, // Cosiall Profile Emails (FREE)
|
|
1002
|
+
trykitt: 3, // TryKitt.ai (FREE for individuals)
|
|
1003
|
+
linkedin: 4, // LinkedIn company lookup (for domain discovery, doesn't provide emails)
|
|
1004
|
+
pattern: 5,
|
|
1005
|
+
bounceban: 6, // BounceBan catch-all verification (FREE single)
|
|
1006
|
+
hunter: 7,
|
|
1007
|
+
bouncer: 8,
|
|
1008
|
+
snovio: 9,
|
|
888
1009
|
};
|
|
889
1010
|
result.emails.sort((a, b) => {
|
|
890
1011
|
if (b.confidence !== a.confidence) {
|
|
@@ -920,12 +1041,15 @@ function extractDomainFromUrl(url) {
|
|
|
920
1041
|
if (!url)
|
|
921
1042
|
return null;
|
|
922
1043
|
try {
|
|
923
|
-
const fullUrl = url.startsWith(
|
|
924
|
-
return new URL(fullUrl).hostname.replace(/^www\./,
|
|
1044
|
+
const fullUrl = url.startsWith("http") ? url : `https://${url}`;
|
|
1045
|
+
return new URL(fullUrl).hostname.replace(/^www\./, "").toLowerCase();
|
|
925
1046
|
}
|
|
926
1047
|
catch {
|
|
927
1048
|
// If URL parsing fails, try simple extraction
|
|
928
|
-
return url
|
|
1049
|
+
return (url
|
|
1050
|
+
.replace(/^(https?:\/\/)?(www\.)?/, "")
|
|
1051
|
+
.split("/")[0]
|
|
1052
|
+
.toLowerCase() || null);
|
|
929
1053
|
}
|
|
930
1054
|
}
|
|
931
1055
|
/**
|
|
@@ -950,13 +1074,13 @@ async function queryLdd(contact, lddConfig, numericLinkedInId, addEmail, result)
|
|
|
950
1074
|
firstName: contact.firstName,
|
|
951
1075
|
lastName: contact.lastName,
|
|
952
1076
|
});
|
|
953
|
-
if (lddResult &&
|
|
1077
|
+
if (lddResult && "emails" in lddResult && lddResult.emails.length > 0) {
|
|
954
1078
|
for (const emailData of lddResult.emails) {
|
|
955
1079
|
addEmail({
|
|
956
1080
|
email: emailData.email,
|
|
957
|
-
source:
|
|
1081
|
+
source: "ldd",
|
|
958
1082
|
confidence: emailData.confidence ?? 90,
|
|
959
|
-
type: emailData.metadata?.emailTypeClassified ||
|
|
1083
|
+
type: emailData.metadata?.emailTypeClassified || "unknown",
|
|
960
1084
|
verified: emailData.verified ?? true,
|
|
961
1085
|
metadata: emailData.metadata,
|
|
962
1086
|
});
|
|
@@ -964,7 +1088,166 @@ async function queryLdd(contact, lddConfig, numericLinkedInId, addEmail, result)
|
|
|
964
1088
|
}
|
|
965
1089
|
}
|
|
966
1090
|
catch (err) {
|
|
967
|
-
result.errors?.push(`LDD: ${err instanceof Error ? err.message :
|
|
1091
|
+
result.errors?.push(`LDD: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Query Cosiall Profile Emails provider
|
|
1096
|
+
*/
|
|
1097
|
+
async function queryCosiall(contact, addEmail, result) {
|
|
1098
|
+
try {
|
|
1099
|
+
// Import dynamically to avoid circular deps
|
|
1100
|
+
const { fetchProfileEmailsFromCosiall } = await Promise.resolve().then(() => __importStar(require("../cosiall-client")));
|
|
1101
|
+
// Build lookup parameters from contact
|
|
1102
|
+
const objectUrn = contact.objectUrn;
|
|
1103
|
+
const linkedInUrl = contact.entityUrn
|
|
1104
|
+
? undefined // entityUrn is not a URL
|
|
1105
|
+
: undefined;
|
|
1106
|
+
// Extract vanity from contact if available
|
|
1107
|
+
// (LinkedInContact doesn't have direct username field, but could be derived)
|
|
1108
|
+
// Must have at least objectUrn to query Cosiall
|
|
1109
|
+
if (!objectUrn) {
|
|
1110
|
+
return; // Silently skip - no identifier available
|
|
1111
|
+
}
|
|
1112
|
+
const cosiallResult = await fetchProfileEmailsFromCosiall({
|
|
1113
|
+
objectUrn,
|
|
1114
|
+
linkedInUrl,
|
|
1115
|
+
});
|
|
1116
|
+
if (cosiallResult.emails && cosiallResult.emails.length > 0) {
|
|
1117
|
+
for (const email of cosiallResult.emails) {
|
|
1118
|
+
addEmail({
|
|
1119
|
+
email,
|
|
1120
|
+
source: "cosiall",
|
|
1121
|
+
confidence: 85, // Good confidence - profile-associated emails
|
|
1122
|
+
type: "unknown", // Cosiall doesn't classify email type
|
|
1123
|
+
verified: true, // These are from LinkedIn profiles
|
|
1124
|
+
metadata: {
|
|
1125
|
+
profileId: cosiallResult.profileId,
|
|
1126
|
+
objectUrn: cosiallResult.objectUrn,
|
|
1127
|
+
linkedInUrl: cosiallResult.linkedInUrl,
|
|
1128
|
+
},
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
catch (err) {
|
|
1134
|
+
// Silently fail for NOT_FOUND - just means profile not in database
|
|
1135
|
+
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
|
1136
|
+
if (!errorMessage.includes("not found") &&
|
|
1137
|
+
!errorMessage.includes("NOT_FOUND")) {
|
|
1138
|
+
result.errors?.push(`Cosiall: ${errorMessage}`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Query TryKitt.ai provider (FREE for individuals)
|
|
1144
|
+
* AI-powered email finder with enterprise identity server catch-all verification
|
|
1145
|
+
*/
|
|
1146
|
+
async function queryTryKitt(contact, trykittConfig, companyDomain, addEmail, result) {
|
|
1147
|
+
try {
|
|
1148
|
+
// Import TryKitt provider dynamically
|
|
1149
|
+
const { createTryKittProvider } = await Promise.resolve().then(() => __importStar(require("./providers/trykitt")));
|
|
1150
|
+
const trykittProvider = createTryKittProvider(trykittConfig);
|
|
1151
|
+
// Build candidate with available data
|
|
1152
|
+
const fullName = contact.fullName || `${contact.firstName} ${contact.lastName}`.trim();
|
|
1153
|
+
// Need both name and domain to search
|
|
1154
|
+
if (!fullName || !companyDomain) {
|
|
1155
|
+
return; // Silently skip - missing required data
|
|
1156
|
+
}
|
|
1157
|
+
const candidate = {
|
|
1158
|
+
fullName,
|
|
1159
|
+
firstName: contact.firstName,
|
|
1160
|
+
lastName: contact.lastName,
|
|
1161
|
+
domain: companyDomain,
|
|
1162
|
+
};
|
|
1163
|
+
// Add LinkedIn URL if available (improves TryKitt accuracy)
|
|
1164
|
+
if (contact.entityUrn) {
|
|
1165
|
+
// entityUrn is like "urn:li:fs_salesProfile:..." - not useful for TryKitt
|
|
1166
|
+
}
|
|
1167
|
+
const trykittResult = await trykittProvider(candidate);
|
|
1168
|
+
if (trykittResult) {
|
|
1169
|
+
// Single result
|
|
1170
|
+
if ("email" in trykittResult && trykittResult.email) {
|
|
1171
|
+
addEmail({
|
|
1172
|
+
email: trykittResult.email,
|
|
1173
|
+
source: "trykitt",
|
|
1174
|
+
confidence: trykittResult.score ?? 80,
|
|
1175
|
+
type: "business",
|
|
1176
|
+
verified: trykittResult.verified ?? false,
|
|
1177
|
+
metadata: {
|
|
1178
|
+
provider: "trykitt",
|
|
1179
|
+
aiPowered: true,
|
|
1180
|
+
},
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
// Multi result
|
|
1184
|
+
else if ("emails" in trykittResult) {
|
|
1185
|
+
const multiResult = trykittResult;
|
|
1186
|
+
for (const emailData of multiResult.emails) {
|
|
1187
|
+
addEmail({
|
|
1188
|
+
email: emailData.email,
|
|
1189
|
+
source: "trykitt",
|
|
1190
|
+
confidence: emailData.confidence ?? 70,
|
|
1191
|
+
type: "business",
|
|
1192
|
+
verified: emailData.verified ?? false,
|
|
1193
|
+
isCatchAll: emailData.isCatchAll,
|
|
1194
|
+
metadata: emailData.metadata,
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
catch (err) {
|
|
1201
|
+
result.errors?.push(`TryKitt: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Query BounceBan for catch-all verification
|
|
1206
|
+
* Verifies pattern-guessed emails with 85-95% accuracy on catch-all domains
|
|
1207
|
+
*/
|
|
1208
|
+
async function queryBounceBan(emailsToVerify, bouncebanConfig, addEmail, result) {
|
|
1209
|
+
try {
|
|
1210
|
+
// Import BounceBan provider dynamically
|
|
1211
|
+
const { verifyEmailWithBounceBan } = await Promise.resolve().then(() => __importStar(require("./providers/bounceban")));
|
|
1212
|
+
for (const emailResult of emailsToVerify) {
|
|
1213
|
+
const verification = await verifyEmailWithBounceBan(emailResult.email, {
|
|
1214
|
+
apiKey: bouncebanConfig.apiKey,
|
|
1215
|
+
useDeepVerify: bouncebanConfig.useDeepVerify ?? false,
|
|
1216
|
+
useWaterfall: bouncebanConfig.useWaterfall ?? true,
|
|
1217
|
+
});
|
|
1218
|
+
if (verification && verification.status === "success") {
|
|
1219
|
+
// Map BounceBan result to confidence
|
|
1220
|
+
let confidence = verification.score;
|
|
1221
|
+
const verified = verification.result === "deliverable";
|
|
1222
|
+
// Reduce confidence for catch-all domains even if deliverable
|
|
1223
|
+
if (verification.is_accept_all && verified) {
|
|
1224
|
+
confidence = Math.min(confidence, 75);
|
|
1225
|
+
}
|
|
1226
|
+
// Only add if it's potentially deliverable
|
|
1227
|
+
if (verification.result === "deliverable" ||
|
|
1228
|
+
verification.result === "risky") {
|
|
1229
|
+
addEmail({
|
|
1230
|
+
email: verification.email,
|
|
1231
|
+
source: "bounceban",
|
|
1232
|
+
confidence,
|
|
1233
|
+
type: "business",
|
|
1234
|
+
verified,
|
|
1235
|
+
isCatchAll: verification.is_accept_all,
|
|
1236
|
+
metadata: {
|
|
1237
|
+
bounceBanResult: verification.result,
|
|
1238
|
+
bounceBanScore: verification.score,
|
|
1239
|
+
smtpProvider: verification.smtp_provider,
|
|
1240
|
+
isDisposable: verification.is_disposable,
|
|
1241
|
+
isRole: verification.is_role,
|
|
1242
|
+
originalSource: emailResult.source,
|
|
1243
|
+
},
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
catch (err) {
|
|
1250
|
+
result.errors?.push(`BounceBan: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
968
1251
|
}
|
|
969
1252
|
}
|
|
970
1253
|
/**
|
|
@@ -975,7 +1258,7 @@ async function querySmartProspect(contact, smartProspectConfig, minMatchConfiden
|
|
|
975
1258
|
try {
|
|
976
1259
|
const client = (0, smartprospect_1.createSmartProspectClient)(smartProspectConfig);
|
|
977
1260
|
if (!client) {
|
|
978
|
-
result.errors?.push(
|
|
1261
|
+
result.errors?.push("SmartProspect: Failed to create client");
|
|
979
1262
|
return null;
|
|
980
1263
|
}
|
|
981
1264
|
// Build search filters
|
|
@@ -1021,21 +1304,23 @@ async function querySmartProspect(contact, smartProspectConfig, minMatchConfiden
|
|
|
1021
1304
|
if (companyWebsite) {
|
|
1022
1305
|
// Clean up the domain (remove protocol, www, trailing slashes)
|
|
1023
1306
|
discoveredDomain = companyWebsite
|
|
1024
|
-
.replace(/^(https?:\/\/)?(www\.)?/,
|
|
1025
|
-
.split(
|
|
1307
|
+
.replace(/^(https?:\/\/)?(www\.)?/, "")
|
|
1308
|
+
.split("/")[0]
|
|
1026
1309
|
.toLowerCase();
|
|
1027
1310
|
}
|
|
1028
1311
|
// Fetch email for matched contact
|
|
1029
|
-
const fetchResponse = await client.fetch([
|
|
1312
|
+
const fetchResponse = await client.fetch([
|
|
1313
|
+
matchResult.smartProspectContact.id,
|
|
1314
|
+
]);
|
|
1030
1315
|
if (fetchResponse.success && fetchResponse.data.list.length > 0) {
|
|
1031
1316
|
const enrichedContact = fetchResponse.data.list[0];
|
|
1032
1317
|
if (enrichedContact.email) {
|
|
1033
1318
|
addEmail({
|
|
1034
1319
|
email: enrichedContact.email,
|
|
1035
|
-
source:
|
|
1320
|
+
source: "smartprospect",
|
|
1036
1321
|
confidence: matchResult.confidence,
|
|
1037
|
-
type:
|
|
1038
|
-
verified: enrichedContact.verificationStatus ===
|
|
1322
|
+
type: "business",
|
|
1323
|
+
verified: enrichedContact.verificationStatus === "verified",
|
|
1039
1324
|
deliverability: enrichedContact.emailDeliverability,
|
|
1040
1325
|
metadata: {
|
|
1041
1326
|
matchQuality: matchResult.quality,
|
|
@@ -1051,7 +1336,7 @@ async function querySmartProspect(contact, smartProspectConfig, minMatchConfiden
|
|
|
1051
1336
|
return discoveredDomain;
|
|
1052
1337
|
}
|
|
1053
1338
|
catch (err) {
|
|
1054
|
-
result.errors?.push(`SmartProspect: ${err instanceof Error ? err.message :
|
|
1339
|
+
result.errors?.push(`SmartProspect: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
1055
1340
|
return null;
|
|
1056
1341
|
}
|
|
1057
1342
|
}
|
|
@@ -1061,20 +1346,25 @@ async function querySmartProspect(contact, smartProspectConfig, minMatchConfiden
|
|
|
1061
1346
|
async function queryPatternGuessing(contact, companyDomain, addEmail, result) {
|
|
1062
1347
|
try {
|
|
1063
1348
|
// Import construct provider dynamically to avoid circular deps
|
|
1064
|
-
const { createConstructProvider } = await Promise.resolve().then(() => __importStar(require(
|
|
1065
|
-
const constructProvider = createConstructProvider({
|
|
1349
|
+
const { createConstructProvider } = await Promise.resolve().then(() => __importStar(require("./providers/construct")));
|
|
1350
|
+
const constructProvider = createConstructProvider({
|
|
1351
|
+
maxAttempts: 12,
|
|
1352
|
+
timeoutMs: 3000,
|
|
1353
|
+
});
|
|
1066
1354
|
const constructResult = await constructProvider({
|
|
1067
1355
|
firstName: contact.firstName,
|
|
1068
1356
|
lastName: contact.lastName,
|
|
1069
1357
|
domain: companyDomain,
|
|
1070
1358
|
});
|
|
1071
|
-
if (constructResult &&
|
|
1359
|
+
if (constructResult &&
|
|
1360
|
+
"emails" in constructResult &&
|
|
1361
|
+
constructResult.emails.length > 0) {
|
|
1072
1362
|
for (const emailData of constructResult.emails) {
|
|
1073
1363
|
addEmail({
|
|
1074
1364
|
email: emailData.email,
|
|
1075
|
-
source:
|
|
1365
|
+
source: "pattern",
|
|
1076
1366
|
confidence: emailData.confidence ?? 50,
|
|
1077
|
-
type:
|
|
1367
|
+
type: "business",
|
|
1078
1368
|
verified: emailData.verified ?? false,
|
|
1079
1369
|
isCatchAll: emailData.isCatchAll,
|
|
1080
1370
|
metadata: emailData.metadata,
|
|
@@ -1083,7 +1373,120 @@ async function queryPatternGuessing(contact, companyDomain, addEmail, result) {
|
|
|
1083
1373
|
}
|
|
1084
1374
|
}
|
|
1085
1375
|
catch (err) {
|
|
1086
|
-
result.errors?.push(`Pattern: ${err instanceof Error ? err.message :
|
|
1376
|
+
result.errors?.push(`Pattern: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Query Hunter.io Email Finder (PAID provider)
|
|
1381
|
+
*
|
|
1382
|
+
* Supports multiple modes:
|
|
1383
|
+
* 1. linkedin_handle - Most accurate (requires getSalesNavigatorProfileFull to get handle)
|
|
1384
|
+
* 2. domain + first_name + last_name
|
|
1385
|
+
* 3. company + first_name + last_name
|
|
1386
|
+
*/
|
|
1387
|
+
async function queryHunter(contact, hunterConfig, companyDomain, linkedinHandle, addEmail, result) {
|
|
1388
|
+
try {
|
|
1389
|
+
// Import Hunter provider dynamically
|
|
1390
|
+
const { createHunterProvider } = await Promise.resolve().then(() => __importStar(require("./providers/hunter")));
|
|
1391
|
+
const hunterProvider = createHunterProvider(hunterConfig);
|
|
1392
|
+
// Build candidate with all available data
|
|
1393
|
+
const candidate = {
|
|
1394
|
+
firstName: contact.firstName,
|
|
1395
|
+
lastName: contact.lastName,
|
|
1396
|
+
};
|
|
1397
|
+
// Add linkedin_handle if available (best for Hunter)
|
|
1398
|
+
if (linkedinHandle) {
|
|
1399
|
+
candidate.linkedinHandle = linkedinHandle;
|
|
1400
|
+
}
|
|
1401
|
+
// Add domain if available
|
|
1402
|
+
if (companyDomain) {
|
|
1403
|
+
candidate.domain = companyDomain;
|
|
1404
|
+
}
|
|
1405
|
+
// Add company name if available
|
|
1406
|
+
const companyName = contact.currentPositions?.[0]?.companyName;
|
|
1407
|
+
if (companyName) {
|
|
1408
|
+
candidate.company = companyName;
|
|
1409
|
+
}
|
|
1410
|
+
const hunterResult = await hunterProvider(candidate);
|
|
1411
|
+
if (hunterResult) {
|
|
1412
|
+
// Single result
|
|
1413
|
+
if ("email" in hunterResult && hunterResult.email) {
|
|
1414
|
+
addEmail({
|
|
1415
|
+
email: hunterResult.email,
|
|
1416
|
+
source: "hunter",
|
|
1417
|
+
confidence: hunterResult.score ?? 70,
|
|
1418
|
+
type: "business",
|
|
1419
|
+
verified: hunterResult.verified ?? false,
|
|
1420
|
+
metadata: {
|
|
1421
|
+
usedLinkedInHandle: !!linkedinHandle,
|
|
1422
|
+
usedDomain: !!companyDomain,
|
|
1423
|
+
usedCompany: !!companyName && !companyDomain && !linkedinHandle,
|
|
1424
|
+
},
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
// Multi result (from domain-search)
|
|
1428
|
+
else if ("emails" in hunterResult && hunterResult.emails.length > 0) {
|
|
1429
|
+
for (const emailData of hunterResult.emails) {
|
|
1430
|
+
addEmail({
|
|
1431
|
+
email: emailData.email,
|
|
1432
|
+
source: "hunter",
|
|
1433
|
+
confidence: emailData.confidence ?? 60,
|
|
1434
|
+
type: "business",
|
|
1435
|
+
verified: emailData.verified ?? false,
|
|
1436
|
+
metadata: emailData.metadata,
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
catch (err) {
|
|
1443
|
+
result.errors?.push(`Hunter: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Query Snovio for email addresses
|
|
1448
|
+
*/
|
|
1449
|
+
async function querySnovio(contact, snovioConfig, companyDomain, addEmail, result) {
|
|
1450
|
+
try {
|
|
1451
|
+
// Import Snovio provider dynamically
|
|
1452
|
+
const { createSnovioProvider } = await Promise.resolve().then(() => __importStar(require("./providers/snovio")));
|
|
1453
|
+
const snovioProvider = createSnovioProvider(snovioConfig);
|
|
1454
|
+
// Build candidate with available data
|
|
1455
|
+
const candidate = {
|
|
1456
|
+
firstName: contact.firstName,
|
|
1457
|
+
lastName: contact.lastName,
|
|
1458
|
+
domain: companyDomain,
|
|
1459
|
+
};
|
|
1460
|
+
const snovioResult = await snovioProvider(candidate);
|
|
1461
|
+
if (snovioResult) {
|
|
1462
|
+
// Single result
|
|
1463
|
+
if ("email" in snovioResult && snovioResult.email) {
|
|
1464
|
+
addEmail({
|
|
1465
|
+
email: snovioResult.email,
|
|
1466
|
+
source: "snovio",
|
|
1467
|
+
confidence: snovioResult.score ?? 70,
|
|
1468
|
+
type: "business",
|
|
1469
|
+
verified: snovioResult.verified ?? false,
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
// Multi result
|
|
1473
|
+
else if ("emails" in snovioResult && snovioResult.emails.length > 0) {
|
|
1474
|
+
for (const emailData of snovioResult.emails) {
|
|
1475
|
+
addEmail({
|
|
1476
|
+
email: emailData.email,
|
|
1477
|
+
source: "snovio",
|
|
1478
|
+
confidence: emailData.confidence ?? 60,
|
|
1479
|
+
type: "business",
|
|
1480
|
+
verified: emailData.verified ?? false,
|
|
1481
|
+
isCatchAll: emailData.isCatchAll,
|
|
1482
|
+
metadata: emailData.metadata,
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
catch (err) {
|
|
1489
|
+
result.errors?.push(`Snovio: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
1087
1490
|
}
|
|
1088
1491
|
}
|
|
1089
1492
|
/**
|