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.
Files changed (44) hide show
  1. package/README.md +50 -21
  2. package/dist/cosiall-client.d.ts +1 -1
  3. package/dist/cosiall-client.js +1 -1
  4. package/dist/enrichment/index.d.ts +1 -1
  5. package/dist/enrichment/index.js +11 -2
  6. package/dist/enrichment/matching.d.ts +16 -2
  7. package/dist/enrichment/matching.js +387 -65
  8. package/dist/enrichment/providers/bounceban.d.ts +82 -0
  9. package/dist/enrichment/providers/bounceban.js +447 -0
  10. package/dist/enrichment/providers/bouncer.d.ts +1 -1
  11. package/dist/enrichment/providers/bouncer.js +19 -21
  12. package/dist/enrichment/providers/construct.d.ts +1 -1
  13. package/dist/enrichment/providers/construct.js +22 -38
  14. package/dist/enrichment/providers/cosiall.d.ts +1 -1
  15. package/dist/enrichment/providers/cosiall.js +3 -4
  16. package/dist/enrichment/providers/dropcontact.d.ts +15 -9
  17. package/dist/enrichment/providers/dropcontact.js +188 -19
  18. package/dist/enrichment/providers/hunter.d.ts +8 -1
  19. package/dist/enrichment/providers/hunter.js +52 -28
  20. package/dist/enrichment/providers/index.d.ts +2 -0
  21. package/dist/enrichment/providers/index.js +10 -1
  22. package/dist/enrichment/providers/ldd.d.ts +1 -10
  23. package/dist/enrichment/providers/ldd.js +20 -97
  24. package/dist/enrichment/providers/smartprospect.js +28 -48
  25. package/dist/enrichment/providers/snovio.d.ts +1 -1
  26. package/dist/enrichment/providers/snovio.js +29 -31
  27. package/dist/enrichment/providers/trykitt.d.ts +63 -0
  28. package/dist/enrichment/providers/trykitt.js +210 -0
  29. package/dist/enrichment/types.d.ts +210 -7
  30. package/dist/enrichment/types.js +16 -8
  31. package/dist/enrichment/utils/candidate-parser.d.ts +107 -0
  32. package/dist/enrichment/utils/candidate-parser.js +173 -0
  33. package/dist/enrichment/utils/noop-provider.d.ts +39 -0
  34. package/dist/enrichment/utils/noop-provider.js +37 -0
  35. package/dist/enrichment/utils/rate-limiter.d.ts +103 -0
  36. package/dist/enrichment/utils/rate-limiter.js +204 -0
  37. package/dist/enrichment/utils/validation.d.ts +75 -3
  38. package/dist/enrichment/utils/validation.js +164 -11
  39. package/dist/linkedin-api.d.ts +40 -1
  40. package/dist/linkedin-api.js +160 -27
  41. package/dist/types.d.ts +50 -1
  42. package/dist/utils/lru-cache.d.ts +105 -0
  43. package/dist/utils/lru-cache.js +175 -0
  44. 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 and name parsing utilities.
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 !== 'string')
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., "Jos" -> "Jose", "Mller" -> "Muller"
97
+ * e.g., "José" -> "Jose", "Müller" -> "Muller"
85
98
  */
86
99
  function asciiFold(s) {
87
100
  try {
88
- return s.normalize('NFD').replace(/\p{Diacritic}+/gu, '');
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 || '').toLowerCase());
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('www.') ? h.slice(4) : h;
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
+ }
@@ -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;
@@ -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
- // In-memory caches (per-process)
63
- const profileCacheByVanity = new Map();
64
- const profileCacheByUrn = new Map();
65
- const searchCache = new Map();
66
- // New caches for Phase 2
67
- const companyCache = new Map();
68
- const typeaheadCache = new Map();
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 = getCached(profileCacheByVanity, key, cfg.profileCacheTtl);
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, { data: prof, ts: Date.now() });
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 = getCached(profileCacheByUrn, cacheKey, cfg.profileCacheTtl);
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, { data: prof, ts: Date.now() });
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 = getCached(searchCache, cacheKey, cfg.searchCacheTtl);
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, { data: result, ts: Date.now() });
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 = getCached(companyCache, key, cfg.companyCacheTtl);
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, { data: parsed, ts: Date.now() });
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 = getCached(typeaheadCache, cacheKey, cfg.typeaheadCacheTtl);
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, { data: result, ts: Date.now() });
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:fsd_profile:ACoAABcdEfG") */
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;