linkedin-secret-sauce 0.12.1 → 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 +1 -1
- package/dist/enrichment/index.js +11 -2
- package/dist/enrichment/matching.d.ts +16 -2
- package/dist/enrichment/matching.js +387 -65
- 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 +1 -1
- package/dist/enrichment/providers/cosiall.js +3 -4
- 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 +2 -0
- package/dist/enrichment/providers/index.js +10 -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 +210 -7
- package/dist/enrichment/types.js +16 -8
- 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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Email Validation Utilities
|
|
3
|
+
* Email and Candidate Validation Utilities
|
|
4
4
|
*
|
|
5
|
-
* Provides email syntax validation
|
|
5
|
+
* Provides email syntax validation, name parsing utilities,
|
|
6
|
+
* and EnrichmentCandidate validation.
|
|
6
7
|
*/
|
|
7
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
9
|
exports.isValidEmailSyntax = isValidEmailSyntax;
|
|
@@ -11,6 +12,15 @@ exports.asciiFold = asciiFold;
|
|
|
11
12
|
exports.cleanNamePart = cleanNamePart;
|
|
12
13
|
exports.hostnameFromUrl = hostnameFromUrl;
|
|
13
14
|
exports.extractLinkedInUsername = extractLinkedInUsername;
|
|
15
|
+
exports.isValidDomain = isValidDomain;
|
|
16
|
+
exports.isValidLinkedInUrl = isValidLinkedInUrl;
|
|
17
|
+
exports.validateCandidate = validateCandidate;
|
|
18
|
+
exports.validateCandidates = validateCandidates;
|
|
19
|
+
exports.filterValidCandidates = filterValidCandidates;
|
|
20
|
+
const candidate_parser_1 = require("./candidate-parser");
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Email Syntax Validation
|
|
23
|
+
// =============================================================================
|
|
14
24
|
/**
|
|
15
25
|
* Validate email syntax
|
|
16
26
|
*
|
|
@@ -18,17 +28,17 @@ exports.extractLinkedInUsername = extractLinkedInUsername;
|
|
|
18
28
|
* @returns true if the email has valid syntax
|
|
19
29
|
*/
|
|
20
30
|
function isValidEmailSyntax(email) {
|
|
21
|
-
if (!email || typeof email !==
|
|
31
|
+
if (!email || typeof email !== "string")
|
|
22
32
|
return false;
|
|
23
|
-
if (email.includes(
|
|
33
|
+
if (email.includes(" "))
|
|
24
34
|
return false;
|
|
25
|
-
const parts = email.split(
|
|
35
|
+
const parts = email.split("@");
|
|
26
36
|
if (parts.length !== 2)
|
|
27
37
|
return false;
|
|
28
38
|
const [local, domain] = parts;
|
|
29
39
|
if (!local || !domain)
|
|
30
40
|
return false;
|
|
31
|
-
if (!domain.includes(
|
|
41
|
+
if (!domain.includes("."))
|
|
32
42
|
return false;
|
|
33
43
|
if (local.length > 64)
|
|
34
44
|
return false;
|
|
@@ -79,13 +89,16 @@ function isRoleAccount(email) {
|
|
|
79
89
|
const emailLower = email.toLowerCase();
|
|
80
90
|
return ROLE_PATTERNS.some((pattern) => pattern.test(emailLower));
|
|
81
91
|
}
|
|
92
|
+
// =============================================================================
|
|
93
|
+
// Name Utilities
|
|
94
|
+
// =============================================================================
|
|
82
95
|
/**
|
|
83
96
|
* ASCII fold diacritics for name normalization
|
|
84
|
-
* e.g., "
|
|
97
|
+
* e.g., "José" -> "Jose", "Müller" -> "Muller"
|
|
85
98
|
*/
|
|
86
99
|
function asciiFold(s) {
|
|
87
100
|
try {
|
|
88
|
-
return s.normalize(
|
|
101
|
+
return s.normalize("NFD").replace(/\p{Diacritic}+/gu, "");
|
|
89
102
|
}
|
|
90
103
|
catch {
|
|
91
104
|
return s;
|
|
@@ -95,9 +108,12 @@ function asciiFold(s) {
|
|
|
95
108
|
* Clean name part: lowercase, remove diacritics, keep only a-z0-9
|
|
96
109
|
*/
|
|
97
110
|
function cleanNamePart(s) {
|
|
98
|
-
const lower = asciiFold(String(s ||
|
|
99
|
-
return lower.replace(/[^a-z0-9]/g,
|
|
111
|
+
const lower = asciiFold(String(s || "").toLowerCase());
|
|
112
|
+
return lower.replace(/[^a-z0-9]/g, "");
|
|
100
113
|
}
|
|
114
|
+
// =============================================================================
|
|
115
|
+
// URL Utilities
|
|
116
|
+
// =============================================================================
|
|
101
117
|
/**
|
|
102
118
|
* Extract hostname from URL
|
|
103
119
|
*
|
|
@@ -110,7 +126,7 @@ function hostnameFromUrl(url) {
|
|
|
110
126
|
try {
|
|
111
127
|
const u = new URL(url);
|
|
112
128
|
const h = u.hostname.toLowerCase();
|
|
113
|
-
return h.startsWith(
|
|
129
|
+
return h.startsWith("www.") ? h.slice(4) : h;
|
|
114
130
|
}
|
|
115
131
|
catch {
|
|
116
132
|
return null;
|
|
@@ -128,3 +144,140 @@ function extractLinkedInUsername(url) {
|
|
|
128
144
|
const match = url.match(/linkedin\.com\/in\/([^/?]+)/);
|
|
129
145
|
return match ? match[1] : null;
|
|
130
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Domain regex pattern
|
|
149
|
+
*/
|
|
150
|
+
const DOMAIN_REGEX = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/;
|
|
151
|
+
/**
|
|
152
|
+
* LinkedIn URL pattern
|
|
153
|
+
*/
|
|
154
|
+
const LINKEDIN_URL_REGEX = /linkedin\.com\/in\/[a-zA-Z0-9_-]+/i;
|
|
155
|
+
/**
|
|
156
|
+
* Validate a domain format
|
|
157
|
+
*/
|
|
158
|
+
function isValidDomain(domain) {
|
|
159
|
+
if (!domain || typeof domain !== "string") {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
return DOMAIN_REGEX.test(domain.trim().toLowerCase());
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Validate a LinkedIn URL format
|
|
166
|
+
*/
|
|
167
|
+
function isValidLinkedInUrl(url) {
|
|
168
|
+
if (!url || typeof url !== "string") {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
return LINKEDIN_URL_REGEX.test(url);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Validate an EnrichmentCandidate object
|
|
175
|
+
*
|
|
176
|
+
* Checks for:
|
|
177
|
+
* - At least one name field present
|
|
178
|
+
* - Valid domain format (if provided)
|
|
179
|
+
* - Valid LinkedIn URL format (if provided)
|
|
180
|
+
* - No completely empty candidate
|
|
181
|
+
*
|
|
182
|
+
* @param candidate - The candidate to validate
|
|
183
|
+
* @returns Validation result with errors and warnings
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* ```typescript
|
|
187
|
+
* const result = validateCandidate({
|
|
188
|
+
* firstName: "John",
|
|
189
|
+
* lastName: "Doe",
|
|
190
|
+
* domain: "example.com"
|
|
191
|
+
* });
|
|
192
|
+
*
|
|
193
|
+
* if (!result.valid) {
|
|
194
|
+
* console.error("Invalid candidate:", result.errors);
|
|
195
|
+
* }
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
function validateCandidate(candidate) {
|
|
199
|
+
const errors = [];
|
|
200
|
+
const warnings = [];
|
|
201
|
+
// Check if candidate is an object
|
|
202
|
+
if (!candidate || typeof candidate !== "object") {
|
|
203
|
+
return {
|
|
204
|
+
valid: false,
|
|
205
|
+
errors: ["Candidate must be a non-null object"],
|
|
206
|
+
warnings: [],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
// Parse the candidate to get normalized fields
|
|
210
|
+
const parsed = (0, candidate_parser_1.parseCandidate)(candidate);
|
|
211
|
+
// Check for at least some identifying information
|
|
212
|
+
const hasName = !!parsed.name.firstName || !!parsed.name.lastName || !!parsed.name.fullName;
|
|
213
|
+
const hasLinkedIn = !!parsed.linkedin.username ||
|
|
214
|
+
!!parsed.linkedin.url ||
|
|
215
|
+
!!parsed.linkedin.numericId;
|
|
216
|
+
const hasDomain = !!parsed.company.domain;
|
|
217
|
+
const hasCompany = !!parsed.company.company;
|
|
218
|
+
if (!hasName && !hasLinkedIn) {
|
|
219
|
+
errors.push("Candidate must have at least a name or LinkedIn identifier");
|
|
220
|
+
}
|
|
221
|
+
// Validate domain format if provided
|
|
222
|
+
if (parsed.company.domain) {
|
|
223
|
+
// Remove common protocol prefixes that users might accidentally include
|
|
224
|
+
const cleanDomain = parsed.company.domain
|
|
225
|
+
.toLowerCase()
|
|
226
|
+
.replace(/^(https?:\/\/)?(www\.)?/, "");
|
|
227
|
+
if (!isValidDomain(cleanDomain)) {
|
|
228
|
+
errors.push(`Invalid domain format: "${parsed.company.domain}"`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Validate LinkedIn URL if provided
|
|
232
|
+
if (parsed.linkedin.url) {
|
|
233
|
+
if (!isValidLinkedInUrl(parsed.linkedin.url)) {
|
|
234
|
+
warnings.push(`LinkedIn URL may be invalid: "${parsed.linkedin.url}"`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Warn if name is very short (might be initial only)
|
|
238
|
+
if (parsed.name.firstName && parsed.name.firstName.length === 1) {
|
|
239
|
+
warnings.push("First name appears to be an initial - results may be less accurate");
|
|
240
|
+
}
|
|
241
|
+
// Warn if no domain/company for providers that need it
|
|
242
|
+
if (!hasDomain && !hasCompany && hasName && !hasLinkedIn) {
|
|
243
|
+
warnings.push("No domain or company provided - only LinkedIn-based providers will work");
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
valid: errors.length === 0,
|
|
247
|
+
errors,
|
|
248
|
+
warnings,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Validate a batch of candidates
|
|
253
|
+
*
|
|
254
|
+
* @param candidates - Array of candidates to validate
|
|
255
|
+
* @returns Array of validation results with index
|
|
256
|
+
*/
|
|
257
|
+
function validateCandidates(candidates) {
|
|
258
|
+
return candidates.map((candidate, index) => ({
|
|
259
|
+
index,
|
|
260
|
+
candidate,
|
|
261
|
+
result: validateCandidate(candidate),
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Filter valid candidates from a batch
|
|
266
|
+
*
|
|
267
|
+
* @param candidates - Array of candidates to filter
|
|
268
|
+
* @returns Object with valid candidates and rejected ones with reasons
|
|
269
|
+
*/
|
|
270
|
+
function filterValidCandidates(candidates) {
|
|
271
|
+
const valid = [];
|
|
272
|
+
const invalid = [];
|
|
273
|
+
candidates.forEach((candidate, index) => {
|
|
274
|
+
const result = validateCandidate(candidate);
|
|
275
|
+
if (result.valid) {
|
|
276
|
+
valid.push(candidate);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
invalid.push({ index, candidate, errors: result.errors });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
return { valid, invalid };
|
|
283
|
+
}
|
package/dist/linkedin-api.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SalesSearchFilters, LinkedInProfile, SearchSalesResult, TypeaheadResult, SalesNavigatorProfile, Company } from "./types";
|
|
1
|
+
import type { SalesSearchFilters, LinkedInProfile, SearchSalesResult, TypeaheadResult, SalesNavigatorProfile, SalesNavigatorProfileFull, Company } from "./types";
|
|
2
2
|
/**
|
|
3
3
|
* Fetches a LinkedIn profile by vanity URL (public identifier).
|
|
4
4
|
* Results are cached for the configured TTL (default: 15 minutes).
|
|
@@ -128,3 +128,42 @@ export declare function getYearsInPositionOptions(): Promise<TypeaheadResult>;
|
|
|
128
128
|
*/
|
|
129
129
|
export declare function getYearsOfExperienceOptions(): Promise<TypeaheadResult>;
|
|
130
130
|
export declare function getSalesNavigatorProfileDetails(profileUrnOrId: string): Promise<SalesNavigatorProfile>;
|
|
131
|
+
/**
|
|
132
|
+
* Fetches full Sales Navigator profile data including flagshipProfileUrl (LinkedIn handle).
|
|
133
|
+
* This is a more complete version that returns all available profile data.
|
|
134
|
+
*
|
|
135
|
+
* The key use case is extracting the LinkedIn handle from flagshipProfileUrl
|
|
136
|
+
* for use with email finder services like Hunter.io.
|
|
137
|
+
*
|
|
138
|
+
* @param profileUrnOrId - Profile identifier in any of these formats:
|
|
139
|
+
* - Sales profile URN: "urn:li:fs_salesProfile:(ABC123xyz,NAME_SEARCH,abc)"
|
|
140
|
+
* - FSD profile URN: "urn:li:fsd_profile:ABC123xyz"
|
|
141
|
+
* - Bare key: "ABC123xyz"
|
|
142
|
+
* @returns Full Sales Navigator profile with flagshipProfileUrl, contactInfo, positions, etc.
|
|
143
|
+
* @throws LinkedInClientError with code NOT_FOUND if profile doesn't exist
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```typescript
|
|
147
|
+
* const profile = await getSalesNavigatorProfileFull('urn:li:fs_salesProfile:(ABC123,NAME_SEARCH,xyz)');
|
|
148
|
+
* console.log(profile.flagshipProfileUrl); // "https://www.linkedin.com/in/john-doe"
|
|
149
|
+
*
|
|
150
|
+
* // Extract LinkedIn handle for Hunter.io
|
|
151
|
+
* const handle = extractLinkedInHandle(profile.flagshipProfileUrl);
|
|
152
|
+
* console.log(handle); // "john-doe"
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
export declare function getSalesNavigatorProfileFull(profileUrnOrId: string): Promise<SalesNavigatorProfileFull>;
|
|
156
|
+
/**
|
|
157
|
+
* Extracts LinkedIn handle/vanity from a flagship profile URL.
|
|
158
|
+
*
|
|
159
|
+
* @param flagshipProfileUrl - Full LinkedIn profile URL (e.g., "https://www.linkedin.com/in/john-doe")
|
|
160
|
+
* @returns LinkedIn handle/vanity (e.g., "john-doe") or null if not found
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```typescript
|
|
164
|
+
* extractLinkedInHandle("https://www.linkedin.com/in/john-doe"); // "john-doe"
|
|
165
|
+
* extractLinkedInHandle("https://www.linkedin.com/in/georgi-metodiev-tech2rec"); // "georgi-metodiev-tech2rec"
|
|
166
|
+
* extractLinkedInHandle(null); // null
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
export declare function extractLinkedInHandle(flagshipProfileUrl: string | null | undefined): string | null;
|
package/dist/linkedin-api.js
CHANGED
|
@@ -47,6 +47,8 @@ exports.getYearsAtCompanyOptions = getYearsAtCompanyOptions;
|
|
|
47
47
|
exports.getYearsInPositionOptions = getYearsInPositionOptions;
|
|
48
48
|
exports.getYearsOfExperienceOptions = getYearsOfExperienceOptions;
|
|
49
49
|
exports.getSalesNavigatorProfileDetails = getSalesNavigatorProfileDetails;
|
|
50
|
+
exports.getSalesNavigatorProfileFull = getSalesNavigatorProfileFull;
|
|
51
|
+
exports.extractLinkedInHandle = extractLinkedInHandle;
|
|
50
52
|
const config_1 = require("./config");
|
|
51
53
|
const http_client_1 = require("./http-client");
|
|
52
54
|
const profile_parser_1 = require("./parsers/profile-parser");
|
|
@@ -57,27 +59,23 @@ const metrics_1 = require("./utils/metrics");
|
|
|
57
59
|
const logger_1 = require("./utils/logger");
|
|
58
60
|
const errors_1 = require("./utils/errors");
|
|
59
61
|
const constants_1 = require("./constants");
|
|
62
|
+
const lru_cache_1 = require("./utils/lru-cache");
|
|
60
63
|
const LINKEDIN_API_BASE = "https://www.linkedin.com/voyager/api";
|
|
61
64
|
const SALES_NAV_BASE = "https://www.linkedin.com/sales-api";
|
|
62
|
-
//
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
65
|
+
// Cache size limits to prevent unbounded memory growth
|
|
66
|
+
const PROFILE_CACHE_MAX_SIZE = 1000;
|
|
67
|
+
const SEARCH_CACHE_MAX_SIZE = 500;
|
|
68
|
+
const COMPANY_CACHE_MAX_SIZE = 500;
|
|
69
|
+
const TYPEAHEAD_CACHE_MAX_SIZE = 200;
|
|
70
|
+
// In-memory LRU caches (per-process) with size limits
|
|
71
|
+
const profileCacheByVanity = new lru_cache_1.LRUCache(PROFILE_CACHE_MAX_SIZE);
|
|
72
|
+
const profileCacheByUrn = new lru_cache_1.LRUCache(PROFILE_CACHE_MAX_SIZE);
|
|
73
|
+
const searchCache = new lru_cache_1.LRUCache(SEARCH_CACHE_MAX_SIZE);
|
|
74
|
+
// Caches for Phase 2
|
|
75
|
+
const companyCache = new lru_cache_1.LRUCache(COMPANY_CACHE_MAX_SIZE);
|
|
76
|
+
const typeaheadCache = new lru_cache_1.LRUCache(TYPEAHEAD_CACHE_MAX_SIZE);
|
|
69
77
|
// In-flight dedupe for profiles by vanity
|
|
70
78
|
const inflightByVanity = new Map();
|
|
71
|
-
function getCached(map, key, ttl) {
|
|
72
|
-
const entry = map.get(key);
|
|
73
|
-
if (!entry)
|
|
74
|
-
return null;
|
|
75
|
-
if (ttl && ttl > 0 && Date.now() - entry.ts > ttl) {
|
|
76
|
-
map.delete(key);
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
return entry.data;
|
|
80
|
-
}
|
|
81
79
|
/**
|
|
82
80
|
* Fetches a LinkedIn profile by vanity URL (public identifier).
|
|
83
81
|
* Results are cached for the configured TTL (default: 15 minutes).
|
|
@@ -104,7 +102,7 @@ async function getProfileByVanity(vanity) {
|
|
|
104
102
|
}
|
|
105
103
|
catch { }
|
|
106
104
|
// Cache
|
|
107
|
-
const cached =
|
|
105
|
+
const cached = profileCacheByVanity.get(key, cfg.profileCacheTtl);
|
|
108
106
|
if (cached) {
|
|
109
107
|
(0, metrics_1.incrementMetric)("profileCacheHits");
|
|
110
108
|
return cached;
|
|
@@ -151,7 +149,7 @@ async function getProfileByVanity(vanity) {
|
|
|
151
149
|
catch { }
|
|
152
150
|
throw new errors_1.LinkedInClientError("Failed to parse profile", errors_1.ERROR_CODES.PARSE_ERROR, 500);
|
|
153
151
|
}
|
|
154
|
-
profileCacheByVanity.set(key,
|
|
152
|
+
profileCacheByVanity.set(key, prof);
|
|
155
153
|
(0, metrics_1.incrementMetric)("profileFetches");
|
|
156
154
|
try {
|
|
157
155
|
(0, logger_1.log)("info", "api.ok", {
|
|
@@ -210,7 +208,7 @@ async function getProfileByUrn(fsdKey) {
|
|
|
210
208
|
});
|
|
211
209
|
}
|
|
212
210
|
catch { }
|
|
213
|
-
const cachedUrn =
|
|
211
|
+
const cachedUrn = profileCacheByUrn.get(cacheKey, cfg.profileCacheTtl);
|
|
214
212
|
if (cachedUrn) {
|
|
215
213
|
(0, metrics_1.incrementMetric)("profileCacheHits");
|
|
216
214
|
return cachedUrn;
|
|
@@ -322,7 +320,7 @@ async function getProfileByUrn(fsdKey) {
|
|
|
322
320
|
catch { }
|
|
323
321
|
throw new errors_1.LinkedInClientError("Failed to parse profile", errors_1.ERROR_CODES.PARSE_ERROR, 500);
|
|
324
322
|
}
|
|
325
|
-
profileCacheByUrn.set(cacheKey,
|
|
323
|
+
profileCacheByUrn.set(cacheKey, prof);
|
|
326
324
|
(0, metrics_1.incrementMetric)("profileFetches");
|
|
327
325
|
try {
|
|
328
326
|
(0, logger_1.log)("info", "api.ok", { operation: "getProfileByUrn", selector: cacheKey });
|
|
@@ -390,7 +388,7 @@ async function searchSalesLeads(keywords, options) {
|
|
|
390
388
|
cacheKeyObj.sessionId = options.sessionId;
|
|
391
389
|
}
|
|
392
390
|
const cacheKey = JSON.stringify(cacheKeyObj);
|
|
393
|
-
const cached =
|
|
391
|
+
const cached = searchCache.get(cacheKey, cfg.searchCacheTtl);
|
|
394
392
|
if (cached) {
|
|
395
393
|
(0, metrics_1.incrementMetric)("searchCacheHits");
|
|
396
394
|
return cached;
|
|
@@ -466,7 +464,7 @@ async function searchSalesLeads(keywords, options) {
|
|
|
466
464
|
: undefined,
|
|
467
465
|
_meta: { sessionId }, // Return sessionId to consumer
|
|
468
466
|
};
|
|
469
|
-
searchCache.set(cacheKey,
|
|
467
|
+
searchCache.set(cacheKey, result);
|
|
470
468
|
(0, metrics_1.incrementMetric)("searchCacheMisses");
|
|
471
469
|
return result;
|
|
472
470
|
}
|
|
@@ -564,7 +562,7 @@ async function resolveCompanyUniversalName(universalName) {
|
|
|
564
562
|
async function getCompanyById(companyId) {
|
|
565
563
|
const cfg = (0, config_1.getConfig)();
|
|
566
564
|
const key = String(companyId || "").trim();
|
|
567
|
-
const cached =
|
|
565
|
+
const cached = companyCache.get(key, cfg.companyCacheTtl);
|
|
568
566
|
if (cached) {
|
|
569
567
|
(0, metrics_1.incrementMetric)("companyCacheHits");
|
|
570
568
|
return cached;
|
|
@@ -578,7 +576,7 @@ async function getCompanyById(companyId) {
|
|
|
578
576
|
const raw = await (0, http_client_1.executeLinkedInRequest)({ url }, "getCompanyById");
|
|
579
577
|
const parsed = (0, company_parser_1.parseCompany)(raw);
|
|
580
578
|
(0, metrics_1.incrementMetric)("companyFetches");
|
|
581
|
-
companyCache.set(key,
|
|
579
|
+
companyCache.set(key, parsed);
|
|
582
580
|
try {
|
|
583
581
|
(0, logger_1.log)("info", "api.ok", { operation: "getCompanyById", selector: key });
|
|
584
582
|
}
|
|
@@ -717,7 +715,7 @@ async function typeahead(options) {
|
|
|
717
715
|
}
|
|
718
716
|
// Make API call to LinkedIn for search queries or unsupported types
|
|
719
717
|
const cacheKey = JSON.stringify({ type, query, start, count });
|
|
720
|
-
const cached =
|
|
718
|
+
const cached = typeaheadCache.get(cacheKey, cfg.typeaheadCacheTtl);
|
|
721
719
|
if (cached) {
|
|
722
720
|
(0, metrics_1.incrementMetric)("typeaheadCacheHits");
|
|
723
721
|
return cached;
|
|
@@ -764,7 +762,7 @@ async function typeahead(options) {
|
|
|
764
762
|
total: paging2?.total,
|
|
765
763
|
},
|
|
766
764
|
};
|
|
767
|
-
typeaheadCache.set(cacheKey,
|
|
765
|
+
typeaheadCache.set(cacheKey, result);
|
|
768
766
|
try {
|
|
769
767
|
(0, logger_1.log)("info", "api.ok", {
|
|
770
768
|
operation: "typeahead",
|
|
@@ -920,3 +918,138 @@ async function getSalesNavigatorProfileDetails(profileUrnOrId) {
|
|
|
920
918
|
throw e;
|
|
921
919
|
}
|
|
922
920
|
}
|
|
921
|
+
/**
|
|
922
|
+
* Fetches full Sales Navigator profile data including flagshipProfileUrl (LinkedIn handle).
|
|
923
|
+
* This is a more complete version that returns all available profile data.
|
|
924
|
+
*
|
|
925
|
+
* The key use case is extracting the LinkedIn handle from flagshipProfileUrl
|
|
926
|
+
* for use with email finder services like Hunter.io.
|
|
927
|
+
*
|
|
928
|
+
* @param profileUrnOrId - Profile identifier in any of these formats:
|
|
929
|
+
* - Sales profile URN: "urn:li:fs_salesProfile:(ABC123xyz,NAME_SEARCH,abc)"
|
|
930
|
+
* - FSD profile URN: "urn:li:fsd_profile:ABC123xyz"
|
|
931
|
+
* - Bare key: "ABC123xyz"
|
|
932
|
+
* @returns Full Sales Navigator profile with flagshipProfileUrl, contactInfo, positions, etc.
|
|
933
|
+
* @throws LinkedInClientError with code NOT_FOUND if profile doesn't exist
|
|
934
|
+
*
|
|
935
|
+
* @example
|
|
936
|
+
* ```typescript
|
|
937
|
+
* const profile = await getSalesNavigatorProfileFull('urn:li:fs_salesProfile:(ABC123,NAME_SEARCH,xyz)');
|
|
938
|
+
* console.log(profile.flagshipProfileUrl); // "https://www.linkedin.com/in/john-doe"
|
|
939
|
+
*
|
|
940
|
+
* // Extract LinkedIn handle for Hunter.io
|
|
941
|
+
* const handle = extractLinkedInHandle(profile.flagshipProfileUrl);
|
|
942
|
+
* console.log(handle); // "john-doe"
|
|
943
|
+
* ```
|
|
944
|
+
*/
|
|
945
|
+
async function getSalesNavigatorProfileFull(profileUrnOrId) {
|
|
946
|
+
const idOrUrn = String(profileUrnOrId || "").trim();
|
|
947
|
+
// Build Sales API path supporting URN triple or explicit profileId/authType/authToken
|
|
948
|
+
let pathSeg = "";
|
|
949
|
+
let pidFromTriple = null;
|
|
950
|
+
// URN with triple
|
|
951
|
+
const mUrn = idOrUrn.match(/^urn:li:(?:fs_salesProfile|fsd_profile):\(([^,\s)]+),([^,\s)]+),([^,\s)]+)\)/i);
|
|
952
|
+
if (mUrn) {
|
|
953
|
+
const [_, pid, authType, authToken] = mUrn;
|
|
954
|
+
pathSeg = `(profileId:${pid},authType:${authType},authToken:${authToken})`;
|
|
955
|
+
pidFromTriple = pid;
|
|
956
|
+
}
|
|
957
|
+
else if (/profileId:/.test(idOrUrn) &&
|
|
958
|
+
/authType:/.test(idOrUrn) &&
|
|
959
|
+
/authToken:/.test(idOrUrn)) {
|
|
960
|
+
// Already the triple form without urn prefix
|
|
961
|
+
pathSeg = `(${idOrUrn.replace(/^\(|\)$/g, "")})`;
|
|
962
|
+
const m = idOrUrn.match(/profileId:([^,\s)]+)/);
|
|
963
|
+
pidFromTriple = m ? m[1] : null;
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
// Fallback to simple id/urn
|
|
967
|
+
pathSeg = idOrUrn.startsWith("urn:")
|
|
968
|
+
? idOrUrn
|
|
969
|
+
: encodeURIComponent(idOrUrn);
|
|
970
|
+
}
|
|
971
|
+
// Decoration 2: Returns flagshipProfileUrl, contactInfo, summary, positions
|
|
972
|
+
// This is the key decoration for email enrichment - flagshipProfileUrl contains the LinkedIn handle
|
|
973
|
+
const decoration = encodeURIComponent("(entityUrn,objectUrn,firstName,lastName,fullName,headline,pronoun,degree,profileUnlockInfo,location,listCount,summary,savedLead,defaultPosition,contactInfo,crmStatus,pendingInvitation,unlocked,flagshipProfileUrl,positions*(companyName,current,new,description,endedOn,posId,startedOn,title,location,companyUrn~fs_salesCompany(entityUrn,name,companyPictureDisplayImage)))");
|
|
974
|
+
const base = `${SALES_NAV_BASE}/salesApiProfiles/`;
|
|
975
|
+
async function requestWith(seg) {
|
|
976
|
+
const url = `${base}${seg}?decoration=${decoration}`;
|
|
977
|
+
return (0, http_client_1.executeLinkedInRequest)({
|
|
978
|
+
url,
|
|
979
|
+
headers: { Referer: "https://www.linkedin.com/sales/search/people" },
|
|
980
|
+
}, "getSalesNavigatorProfileFull");
|
|
981
|
+
}
|
|
982
|
+
try {
|
|
983
|
+
try {
|
|
984
|
+
(0, logger_1.log)("info", "api.start", {
|
|
985
|
+
operation: "getSalesNavigatorProfileFull",
|
|
986
|
+
selector: idOrUrn,
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
catch { }
|
|
990
|
+
let raw;
|
|
991
|
+
try {
|
|
992
|
+
raw = await requestWith(pathSeg);
|
|
993
|
+
}
|
|
994
|
+
catch (e) {
|
|
995
|
+
const status = e?.status ?? 0;
|
|
996
|
+
if (status === 400 && pidFromTriple) {
|
|
997
|
+
// Fallback: some environments reject the triple form; retry with bare id
|
|
998
|
+
const fallbackSeg = encodeURIComponent(pidFromTriple);
|
|
999
|
+
try {
|
|
1000
|
+
(0, logger_1.log)("warn", "api.salesProfileFullFallback", {
|
|
1001
|
+
from: pathSeg,
|
|
1002
|
+
to: fallbackSeg,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
catch { }
|
|
1006
|
+
raw = await requestWith(fallbackSeg);
|
|
1007
|
+
}
|
|
1008
|
+
else if (status === 404) {
|
|
1009
|
+
try {
|
|
1010
|
+
(0, logger_1.log)("warn", "api.notFound", {
|
|
1011
|
+
operation: "getSalesNavigatorProfileFull",
|
|
1012
|
+
selector: idOrUrn,
|
|
1013
|
+
status,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
catch { }
|
|
1017
|
+
throw new errors_1.LinkedInClientError("Sales profile not found", errors_1.ERROR_CODES.NOT_FOUND, 404);
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
throw e;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
try {
|
|
1024
|
+
(0, logger_1.log)("info", "api.ok", {
|
|
1025
|
+
operation: "getSalesNavigatorProfileFull",
|
|
1026
|
+
selector: idOrUrn,
|
|
1027
|
+
hasHandle: !!raw.flagshipProfileUrl,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
catch { }
|
|
1031
|
+
return raw;
|
|
1032
|
+
}
|
|
1033
|
+
catch (e) {
|
|
1034
|
+
throw e;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Extracts LinkedIn handle/vanity from a flagship profile URL.
|
|
1039
|
+
*
|
|
1040
|
+
* @param flagshipProfileUrl - Full LinkedIn profile URL (e.g., "https://www.linkedin.com/in/john-doe")
|
|
1041
|
+
* @returns LinkedIn handle/vanity (e.g., "john-doe") or null if not found
|
|
1042
|
+
*
|
|
1043
|
+
* @example
|
|
1044
|
+
* ```typescript
|
|
1045
|
+
* extractLinkedInHandle("https://www.linkedin.com/in/john-doe"); // "john-doe"
|
|
1046
|
+
* extractLinkedInHandle("https://www.linkedin.com/in/georgi-metodiev-tech2rec"); // "georgi-metodiev-tech2rec"
|
|
1047
|
+
* extractLinkedInHandle(null); // null
|
|
1048
|
+
* ```
|
|
1049
|
+
*/
|
|
1050
|
+
function extractLinkedInHandle(flagshipProfileUrl) {
|
|
1051
|
+
if (!flagshipProfileUrl)
|
|
1052
|
+
return null;
|
|
1053
|
+
const match = flagshipProfileUrl.match(/linkedin\.com\/in\/([^\/\?]+)/i);
|
|
1054
|
+
return match ? match[1] : null;
|
|
1055
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export interface CosiallProfileEmailsResponse {
|
|
6
6
|
/** Cosiall internal profile ID */
|
|
7
7
|
profileId: number;
|
|
8
|
-
/** LinkedIn ObjectURN identifier (e.g., "urn:li:
|
|
8
|
+
/** LinkedIn ObjectURN identifier (e.g., "urn:li:member:129147375") */
|
|
9
9
|
objectUrn: string;
|
|
10
10
|
/** LinkedIn profile URL */
|
|
11
11
|
linkedInUrl: string;
|
|
@@ -233,6 +233,55 @@ export interface SalesNavigatorProfile {
|
|
|
233
233
|
imageUrl?: string;
|
|
234
234
|
flagshipProfileUrl?: string;
|
|
235
235
|
}
|
|
236
|
+
export interface SalesNavigatorPosition {
|
|
237
|
+
companyName?: string;
|
|
238
|
+
companyUrn?: string;
|
|
239
|
+
title?: string;
|
|
240
|
+
description?: string;
|
|
241
|
+
current?: boolean;
|
|
242
|
+
new?: boolean;
|
|
243
|
+
location?: string;
|
|
244
|
+
posId?: number;
|
|
245
|
+
startedOn?: {
|
|
246
|
+
year?: number;
|
|
247
|
+
month?: number;
|
|
248
|
+
};
|
|
249
|
+
endedOn?: {
|
|
250
|
+
year?: number;
|
|
251
|
+
month?: number;
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
export interface SalesNavigatorContactInfo {
|
|
255
|
+
emailAddresses?: string[];
|
|
256
|
+
phoneNumbers?: string[];
|
|
257
|
+
twitterHandles?: string[];
|
|
258
|
+
websites?: string[];
|
|
259
|
+
}
|
|
260
|
+
export interface SalesNavigatorProfileFull {
|
|
261
|
+
entityUrn?: string;
|
|
262
|
+
objectUrn?: string;
|
|
263
|
+
firstName?: string;
|
|
264
|
+
lastName?: string;
|
|
265
|
+
fullName?: string;
|
|
266
|
+
headline?: string;
|
|
267
|
+
summary?: string;
|
|
268
|
+
location?: string;
|
|
269
|
+
degree?: number;
|
|
270
|
+
pronoun?: string;
|
|
271
|
+
/** LinkedIn public profile URL - contains the vanity/handle (e.g., "https://www.linkedin.com/in/john-doe") */
|
|
272
|
+
flagshipProfileUrl?: string;
|
|
273
|
+
/** Contact info (emails, phones) - only available if connected */
|
|
274
|
+
contactInfo?: SalesNavigatorContactInfo;
|
|
275
|
+
/** Current and past positions */
|
|
276
|
+
positions?: SalesNavigatorPosition[];
|
|
277
|
+
defaultPosition?: SalesNavigatorPosition;
|
|
278
|
+
profileUnlockInfo?: unknown;
|
|
279
|
+
listCount?: number;
|
|
280
|
+
savedLead?: boolean;
|
|
281
|
+
crmStatus?: unknown;
|
|
282
|
+
pendingInvitation?: boolean;
|
|
283
|
+
unlocked?: boolean;
|
|
284
|
+
}
|
|
236
285
|
export type Geo = {
|
|
237
286
|
type: "region" | "country" | "state" | "city" | "postal";
|
|
238
287
|
value: string;
|