linkedin-secret-sauce 0.3.28 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,22 +3,32 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.stableStringify = stableStringify;
4
4
  exports.buildFilterSignature = buildFilterSignature;
5
5
  exports.buildLeadSearchQuery = buildLeadSearchQuery;
6
- // Stable stringify for cache keys: sorts object keys recursively.
6
+ /**
7
+ * Creates a deterministic string representation of an object for use as cache keys.
8
+ * Sorts object keys recursively to ensure consistent output regardless of property order.
9
+ *
10
+ * @param obj - Any JSON-serializable value
11
+ * @returns Stable string representation suitable for cache key usage
12
+ */
7
13
  function stableStringify(obj) {
8
- if (obj === null || typeof obj !== 'object')
14
+ if (obj === null || typeof obj !== "object")
9
15
  return JSON.stringify(obj);
10
16
  if (Array.isArray(obj))
11
- return '[' + obj.map((v) => stableStringify(v)).join(',') + ']';
17
+ return "[" + obj.map((v) => stableStringify(v)).join(",") + "]";
12
18
  const rec = obj;
13
19
  const keys = Object.keys(rec).sort();
14
- return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(rec[k])).join(',') + '}';
20
+ return ("{" +
21
+ keys
22
+ .map((k) => JSON.stringify(k) + ":" + stableStringify(rec[k]))
23
+ .join(",") +
24
+ "}");
15
25
  }
16
26
  function buildFilterSignature(filters, rawQuery) {
17
27
  if (rawQuery)
18
- return 'rq:' + String(rawQuery).trim();
28
+ return "rq:" + String(rawQuery).trim();
19
29
  if (!filters)
20
30
  return undefined;
21
- return 'f:' + stableStringify(filters);
31
+ return "f:" + stableStringify(filters);
22
32
  }
23
33
  // Temporary minimal encoder: today we include only keywords in the query structure to
24
34
  // preserve existing behavior. Once facet ids are confirmed, extend this to encode filters
@@ -29,93 +39,135 @@ function encText(s) {
29
39
  return encodeURIComponent(String(s));
30
40
  }
31
41
  function list(values) {
32
- return `List(${values.join(',')})`;
42
+ return `List(${values.join(",")})`;
33
43
  }
34
44
  function valObj(parts) {
35
- return `(${parts.join(',')})`;
45
+ return `(${parts.join(",")})`;
36
46
  }
37
47
  function pushFilter(out, type, values) {
38
48
  if (!values.length)
39
49
  return;
40
50
  out.push(valObj([`type:${type}`, `values:${list(values)}`]));
41
51
  }
52
+ /**
53
+ * Builds a Sales Navigator search query string from keywords and filters.
54
+ * Encodes the query in LinkedIn's proprietary format for the salesApiLeadSearch endpoint.
55
+ *
56
+ * @param keywords - Search keywords to include in the query
57
+ * @param filters - Optional SalesSearchFilters object with advanced criteria:
58
+ * - role: seniority_ids, current_titles, past_titles, functions
59
+ * - company: current, past, headcount, type
60
+ * - personal: geography, industry_ids, profile_language, years_* ranges
61
+ * @returns Encoded query string ready for use in Sales Navigator API URL
62
+ *
63
+ * @example
64
+ * ```typescript
65
+ * // Simple keyword search
66
+ * const query = buildLeadSearchQuery('CEO');
67
+ * // Returns: "(keywords:CEO)"
68
+ *
69
+ * // With filters
70
+ * const query = buildLeadSearchQuery('', {
71
+ * role: { seniority_ids: [9, 10] },
72
+ * company: { headcount: { include: ['51-200', '201-500'] } }
73
+ * });
74
+ * ```
75
+ */
42
76
  function buildLeadSearchQuery(keywords, filters) {
43
- const kw = (filters?.boolean?.keywords ?? keywords ?? '').toString();
44
- const encodedKw = kw.replace(/\s+/g, '%20');
77
+ const kw = (filters?.boolean?.keywords ?? keywords ?? "").toString();
78
+ // Manually encode characters that encodeURIComponent doesn't handle properly in LinkedIn query context
79
+ const encodedKw = kw
80
+ .replace(/\(/g, "%28") // Encode (
81
+ .replace(/\)/g, "%29") // Encode )
82
+ .replace(/\[/g, "%5B") // Encode [
83
+ .replace(/\]/g, "%5D") // Encode ]
84
+ .replace(/\{/g, "%7B") // Encode {
85
+ .replace(/\}/g, "%7D") // Encode }
86
+ .replace(/&/g, "%26") // Encode &
87
+ .replace(/\+/g, "%2B") // Encode +
88
+ .replace(/=/g, "%3D") // Encode =
89
+ .replace(/\s+/g, "%20"); // Encode spaces
45
90
  const f = [];
46
91
  // SENIORITY_LEVEL
47
92
  if (filters?.role?.seniority_ids?.length) {
48
93
  const values = filters.role.seniority_ids.map((id) => valObj([`id:${id}`, `selectionType:INCLUDED`]));
49
- pushFilter(f, 'SENIORITY_LEVEL', values);
94
+ pushFilter(f, "SENIORITY_LEVEL", values);
50
95
  }
51
96
  // CURRENT_TITLE / PAST_TITLE (supports id or text)
52
97
  function encodeTitles(arr) {
53
98
  if (!arr || !arr.length)
54
99
  return [];
55
100
  return arr.map((t) => {
56
- if (typeof t === 'object') {
101
+ if (typeof t === "object") {
57
102
  const parts = [];
58
103
  if (t.id != null)
59
104
  parts.push(`id:${t.id}`);
60
105
  if (t.text)
61
106
  parts.push(`text:${encText(t.text)}`);
62
- parts.push('selectionType:INCLUDED');
107
+ parts.push("selectionType:INCLUDED");
63
108
  return valObj(parts);
64
109
  }
65
110
  else {
66
111
  // Heuristic: allow numeric strings to be treated as ids, otherwise text
67
112
  const isNumeric = /^\d+$/.test(t);
68
- const parts = [isNumeric ? `id:${t}` : `text:${encText(t)}`, 'selectionType:INCLUDED'];
113
+ const parts = [
114
+ isNumeric ? `id:${t}` : `text:${encText(t)}`,
115
+ "selectionType:INCLUDED",
116
+ ];
69
117
  return valObj(parts);
70
118
  }
71
119
  });
72
120
  }
73
121
  const curTitles = encodeTitles(filters?.role?.current_titles?.include);
74
122
  if (curTitles.length)
75
- pushFilter(f, 'CURRENT_TITLE', curTitles);
123
+ pushFilter(f, "CURRENT_TITLE", curTitles);
76
124
  const pastTitles = encodeTitles(filters?.role?.past_titles?.include);
77
125
  if (pastTitles.length)
78
- pushFilter(f, 'PAST_TITLE', pastTitles);
126
+ pushFilter(f, "PAST_TITLE", pastTitles);
79
127
  // FUNCTION (ids as strings)
80
128
  if (filters?.role?.functions?.include?.length) {
81
- const values = filters.role.functions.include.map((id) => valObj([`id:${id}`, 'selectionType:INCLUDED']));
82
- pushFilter(f, 'FUNCTION', values);
129
+ const values = filters.role.functions.include.map((id) => valObj([`id:${id}`, "selectionType:INCLUDED"]));
130
+ pushFilter(f, "FUNCTION", values);
83
131
  }
84
132
  // INDUSTRY (numeric ids)
85
133
  if (filters?.personal?.industry_ids?.include?.length) {
86
- const values = filters.personal.industry_ids.include.map((id) => valObj([`id:${id}`, 'selectionType:INCLUDED']));
87
- pushFilter(f, 'INDUSTRY', values);
134
+ const values = filters.personal.industry_ids.include.map((id) => valObj([`id:${id}`, "selectionType:INCLUDED"]));
135
+ pushFilter(f, "INDUSTRY", values);
88
136
  }
89
137
  // PROFILE_LANGUAGE (ids like en, ar)
90
138
  if (filters?.personal?.profile_language?.include?.length) {
91
- const values = filters.personal.profile_language.include.map((id) => valObj([`id:${id}`, 'selectionType:INCLUDED']));
92
- pushFilter(f, 'PROFILE_LANGUAGE', values);
139
+ const values = filters.personal.profile_language.include.map((id) => valObj([`id:${id}`, "selectionType:INCLUDED"]));
140
+ pushFilter(f, "PROFILE_LANGUAGE", values);
93
141
  }
94
142
  // REGION (BING_GEO) — require id present
95
143
  if (filters?.personal?.geography?.include?.length) {
96
144
  const values = (filters.personal.geography.include || [])
97
145
  .filter((g) => g.id)
98
- .map((g) => valObj([`id:${g.id}`, `text:${encText(g.value)}`, 'selectionType:INCLUDED']));
99
- pushFilter(f, 'REGION', values);
146
+ .map((g) => valObj([
147
+ `id:${g.id}`,
148
+ `text:${encText(g.value)}`,
149
+ "selectionType:INCLUDED",
150
+ ]));
151
+ pushFilter(f, "REGION", values);
100
152
  }
101
153
  // COMPANY_HEADCOUNT — map common labels to A..I; accept raw letters too
102
154
  const HEADCOUNT_CODE = {
103
- 'Self-employed': 'A',
104
- '1-10': 'B',
105
- '11-50': 'C',
106
- '51-200': 'D',
107
- '201-500': 'E',
108
- '501-1000': 'F',
109
- '1001-5000': 'G',
110
- '5001-10000': 'H',
111
- '10000+': 'I',
155
+ "Self-employed": "A",
156
+ "1-10": "B",
157
+ "11-50": "C",
158
+ "51-200": "D",
159
+ "201-500": "E",
160
+ "501-1000": "F",
161
+ "1001-5000": "G",
162
+ "5001-10000": "H",
163
+ "10000+": "I",
112
164
  };
113
165
  if (filters?.company?.headcount?.include?.length) {
114
166
  const values = filters.company.headcount.include.map((label) => {
115
167
  const code = HEADCOUNT_CODE[label] ?? label; // allow passing A..I directly
116
- return valObj([`id:${code}`, 'selectionType:INCLUDED']);
168
+ return valObj([`id:${code}`, "selectionType:INCLUDED"]);
117
169
  });
118
- pushFilter(f, 'COMPANY_HEADCOUNT', values);
170
+ pushFilter(f, "COMPANY_HEADCOUNT", values);
119
171
  }
120
172
  // CURRENT_COMPANY — accept org URN (urn:li:organization:ID) or numeric ID (auto-converted to URN)
121
173
  function encodeCompanies(arr) {
@@ -135,41 +187,44 @@ function buildLeadSearchQuery(keywords, filters) {
135
187
  // If not URN or numeric, assume it's already a URN (pass through)
136
188
  urn = idStr;
137
189
  }
138
- return valObj([`id:${encodeURIComponent(urn)}`, 'selectionType:INCLUDED']);
190
+ return valObj([
191
+ `id:${encodeURIComponent(urn)}`,
192
+ "selectionType:INCLUDED",
193
+ ]);
139
194
  });
140
195
  }
141
196
  const curCompanies = encodeCompanies(filters?.company?.current?.include);
142
197
  if (curCompanies.length)
143
- pushFilter(f, 'CURRENT_COMPANY', curCompanies);
198
+ pushFilter(f, "CURRENT_COMPANY", curCompanies);
144
199
  // PAST_COMPANY — same logic as current
145
200
  const pastCompanies = encodeCompanies(filters?.company?.past?.include);
146
201
  if (pastCompanies.length)
147
- pushFilter(f, 'PAST_COMPANY', pastCompanies);
202
+ pushFilter(f, "PAST_COMPANY", pastCompanies);
148
203
  // COMPANY_TYPE — map to C,P,N,D,G when known; allow raw codes
149
204
  const COMPANY_TYPE = {
150
- PUBLIC: 'C',
151
- PRIVATE: 'P',
152
- NONPROFIT: 'N',
153
- EDUCATIONAL: 'D',
154
- GOVERNMENT: 'G',
205
+ PUBLIC: "C",
206
+ PRIVATE: "P",
207
+ NONPROFIT: "N",
208
+ EDUCATIONAL: "D",
209
+ GOVERNMENT: "G",
155
210
  };
156
211
  if (filters?.company?.type?.include?.length) {
157
212
  const values = filters.company.type.include.map((t) => {
158
213
  const code = COMPANY_TYPE[t] ?? t; // allow raw letter code
159
- return valObj([`id:${code}`, 'selectionType:INCLUDED']);
214
+ return valObj([`id:${code}`, "selectionType:INCLUDED"]);
160
215
  });
161
- pushFilter(f, 'COMPANY_TYPE', values);
216
+ pushFilter(f, "COMPANY_TYPE", values);
162
217
  }
163
218
  // YEARS_* via explicit bucket ids if provided (1..5)
164
219
  const yr = filters?.personal;
165
220
  if (yr?.years_at_company_ids?.length) {
166
- pushFilter(f, 'YEARS_AT_CURRENT_COMPANY', yr.years_at_company_ids.map((id) => valObj([`id:${id}`, 'selectionType:INCLUDED'])));
221
+ pushFilter(f, "YEARS_AT_CURRENT_COMPANY", yr.years_at_company_ids.map((id) => valObj([`id:${id}`, "selectionType:INCLUDED"])));
167
222
  }
168
223
  if (yr?.years_in_current_role_ids?.length) {
169
- pushFilter(f, 'YEARS_IN_CURRENT_POSITION', yr.years_in_current_role_ids.map((id) => valObj([`id:${id}`, 'selectionType:INCLUDED'])));
224
+ pushFilter(f, "YEARS_IN_CURRENT_POSITION", yr.years_in_current_role_ids.map((id) => valObj([`id:${id}`, "selectionType:INCLUDED"])));
170
225
  }
171
226
  if (yr?.years_experience_ids?.length) {
172
- pushFilter(f, 'YEARS_OF_EXPERIENCE', yr.years_experience_ids.map((id) => valObj([`id:${id}`, 'selectionType:INCLUDED'])));
227
+ pushFilter(f, "YEARS_OF_EXPERIENCE", yr.years_experience_ids.map((id) => valObj([`id:${id}`, "selectionType:INCLUDED"])));
173
228
  }
174
229
  // Build query with minimal format for maximum compatibility
175
230
  // LinkedIn rejects spellCorrectionEnabled:true,keywords: with 400 errors
@@ -183,5 +238,5 @@ function buildLeadSearchQuery(keywords, filters) {
183
238
  parts.push(`filters:${list(f)}`);
184
239
  }
185
240
  // Return minimal format - no spellCorrectionEnabled wrapper
186
- return parts.length > 0 ? `(${parts.join(',')})` : '()';
241
+ return parts.length > 0 ? `(${parts.join(",")})` : "()";
187
242
  }
@@ -1,6 +1,6 @@
1
1
  export interface SentryClient {
2
2
  captureException(error: Error, context?: Record<string, unknown>): void;
3
- captureMessage(message: string, level: 'error' | 'warning' | 'info', context?: Record<string, unknown>): void;
3
+ captureMessage(message: string, level: "error" | "warning" | "info", context?: Record<string, unknown>): void;
4
4
  }
5
5
  export declare function setSentryClient(client: SentryClient): void;
6
6
  export declare function reportToSentry(error: Error | string, context?: Record<string, unknown>): void;
@@ -6,42 +6,90 @@ exports.reportWarningToSentry = reportWarningToSentry;
6
6
  exports.reportCriticalError = reportCriticalError;
7
7
  const logger_1 = require("./logger");
8
8
  let sentryClient = null;
9
+ // Deduplication: track recent error reports to prevent flooding Sentry
10
+ const recentReports = new Map();
11
+ const DEDUP_INTERVAL_MS = 60_000; // Only report same error once per minute
12
+ const MAX_RECENT_REPORTS = 1000; // Prevent memory growth
13
+ /**
14
+ * Generate a deduplication key for an error/message
15
+ */
16
+ function getDedupKey(message, level) {
17
+ return `${level}:${message}`;
18
+ }
19
+ /**
20
+ * Check if we should report this error (deduplication)
21
+ * Returns true if we should report, false if deduplicated
22
+ */
23
+ function shouldReport(key) {
24
+ const now = Date.now();
25
+ const lastReport = recentReports.get(key);
26
+ if (lastReport && now - lastReport < DEDUP_INTERVAL_MS) {
27
+ return false; // Skip - reported recently
28
+ }
29
+ // Cleanup old entries if map is too large
30
+ if (recentReports.size >= MAX_RECENT_REPORTS) {
31
+ const cutoff = now - DEDUP_INTERVAL_MS;
32
+ for (const [k, ts] of recentReports.entries()) {
33
+ if (ts < cutoff) {
34
+ recentReports.delete(k);
35
+ }
36
+ }
37
+ }
38
+ recentReports.set(key, now);
39
+ return true;
40
+ }
9
41
  function setSentryClient(client) {
10
42
  sentryClient = client;
11
- (0, logger_1.log)('info', 'sentry.configured', {});
43
+ (0, logger_1.log)("info", "sentry.configured", {});
12
44
  }
13
45
  function reportToSentry(error, context) {
14
46
  if (!sentryClient)
15
47
  return;
48
+ const message = typeof error === "string" ? error : error.message;
49
+ const key = getDedupKey(message, "error");
50
+ if (!shouldReport(key)) {
51
+ (0, logger_1.log)("debug", "sentry.deduplicated", { message });
52
+ return;
53
+ }
16
54
  try {
17
- if (typeof error === 'string') {
18
- sentryClient.captureMessage(error, 'error', context);
55
+ if (typeof error === "string") {
56
+ sentryClient.captureMessage(error, "error", context);
19
57
  }
20
58
  else {
21
59
  sentryClient.captureException(error, context);
22
60
  }
23
61
  }
24
62
  catch (err) {
25
- (0, logger_1.log)('error', 'sentry.reportFailed', { error: err.message });
63
+ (0, logger_1.log)("error", "sentry.reportFailed", { error: err.message });
26
64
  }
27
65
  }
28
66
  function reportWarningToSentry(message, context) {
29
67
  if (!sentryClient)
30
68
  return;
69
+ const key = getDedupKey(message, "warning");
70
+ if (!shouldReport(key)) {
71
+ (0, logger_1.log)("debug", "sentry.deduplicated", { message });
72
+ return;
73
+ }
31
74
  try {
32
- sentryClient.captureMessage(message, 'warning', context);
75
+ sentryClient.captureMessage(message, "warning", context);
33
76
  }
34
77
  catch (err) {
35
- (0, logger_1.log)('error', 'sentry.warningFailed', { error: err.message });
78
+ (0, logger_1.log)("error", "sentry.warningFailed", { error: err.message });
36
79
  }
37
80
  }
38
81
  function reportCriticalError(message, context) {
39
82
  if (!sentryClient)
40
83
  return;
84
+ const key = getDedupKey(message, "critical");
85
+ if (!shouldReport(key)) {
86
+ (0, logger_1.log)("debug", "sentry.deduplicated", { message });
87
+ return;
88
+ }
41
89
  try {
42
- sentryClient.captureMessage(message, 'error', context);
90
+ sentryClient.captureMessage(message, "error", context);
43
91
  }
44
92
  catch (err) {
45
- (0, logger_1.log)('error', 'sentry.criticalFailed', { error: err.message });
93
+ (0, logger_1.log)("error", "sentry.criticalFailed", { error: err.message });
46
94
  }
47
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkedin-secret-sauce",
3
- "version": "0.3.28",
3
+ "version": "0.4.0",
4
4
  "description": "Private LinkedIn Sales Navigator client with automatic cookie management",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",