linkedin-secret-sauce 0.1.1 → 0.1.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 CHANGED
@@ -1,95 +1,228 @@
1
- LinkedIn Secret Sauce Client
1
+ # LinkedIn Secret Sauce Client
2
2
 
3
- Private LinkedIn Sales Navigator client for server-side use. It handles cookie/account rotation, retries, caching, parsing, logging, and metrics so apps just call a simple API.
3
+ Private, server-side LinkedIn Sales Navigator client. It manages cookies/accounts, retries, caching, and parsing so your app calls a small, typed API.
4
+
5
+ For a step‑by‑step integration guide you can copy/paste into other apps, see `docs/INTEGRATION.md`.
6
+
7
+ Advanced lead search
8
+ - Strongly‑typed filters: see `docs/SALES_SEARCH.md` (seniority, titles with id or text, functions, industries, languages, regions, company headcount/type, years buckets, current company).
9
+ - Relationship filters are intentionally not used (results are customer‑agnostic).
10
+ - Past company is not emitted — only CURRENT_COMPANY is supported.
11
+ - Power users: pass a full `rawQuery` string to `searchSalesLeads` if you already have the Sales Navigator `query=(...)` payload.
4
12
 
5
13
  - Node: >= 18 (uses global `fetch`)
6
- - Build: `pnpm build` (or `npm run build`)
7
- - Test: `pnpm test` (or `npm test`)
14
+ - Build: `pnpm build`
15
+ - Test: `pnpm test`
8
16
 
9
17
  ## Install
10
18
 
11
- From npm registry (recommended):
12
-
13
19
  ```bash
14
- # pnpm
20
+ # pnpm (recommended)
15
21
  pnpm add linkedin-secret-sauce
16
22
 
17
- # or npm npm install linkedin-secret-sauce
23
+ # npm
24
+ npm install linkedin-secret-sauce
18
25
  ```
19
26
 
20
- From Git (alternative):
27
+ ## Quick Start
21
28
 
22
- ```bash
23
- pnpm add github:enerage/LinkedInSecretSouce#v0.1.0
24
- ```
29
+ ```ts
30
+ import {
31
+ initializeLinkedInClient,
32
+ getProfileByVanity,
33
+ getProfileByUrn,
34
+ searchSalesLeads,
35
+ resolveCompanyUniversalName,
36
+ getCompanyById,
37
+ getCompanyByUrl,
38
+ typeahead,
39
+ getSalesNavigatorProfileDetails,
40
+ type LinkedInProfile,
41
+ type SearchSalesResult,
42
+ type Company,
43
+ type TypeaheadResult,
44
+ type SalesNavigatorProfile,
45
+ } from 'linkedin-secret-sauce';
25
46
 
26
- Note: Import path is the package name (`linkedin-secret-sauce`).
47
+ initializeLinkedInClient({
48
+ cosiallApiUrl: process.env.COSIALL_API_URL!,
49
+ cosiallApiKey: process.env.COSIALL_API_KEY!,
50
+ proxyString: process.env.LINKEDIN_PROXY_STRING, // optional
51
+ logLevel: (process.env.LOG_LEVEL as any) || 'info',
52
+ });
53
+
54
+ const prof: LinkedInProfile = await getProfileByVanity('john-doe');
55
+ const prof2 = await getProfileByUrn('urn:li:fsd_profile:ACwAAAbCdEf'); // URN or bare key
56
+
57
+ // Search with paging; on HTTP 400, decoration -14 falls back to -17
58
+ const leads: SearchSalesResult = await searchSalesLeads('cto fintech', { start: 50, count: 10 });
59
+
60
+ // Companies
61
+ const { companyId } = await resolveCompanyUniversalName('microsoft');
62
+ const company: Company = await getCompanyById(companyId!);
63
+ const sameCompany = await getCompanyByUrl('https://www.linkedin.com/company/microsoft/');
64
+
65
+ // Typeahead (facet suggestions)
66
+ const ta: TypeaheadResult = await typeahead({ type: 'BING_GEO', query: 'new', start: 0, count: 10 });
27
67
 
28
- ## Configure (env or code)
68
+ // Sales Navigator profile details
69
+ const sales: SalesNavigatorProfile = await getSalesNavigatorProfileDetails('urn:li:fs_salesProfile:ACwAAA...');
70
+ ```
71
+
72
+ ## Environment & Config
29
73
 
30
74
  Required
31
- - `cosiallApiUrl`: Base URL of your Cosiall service (e.g., https://cosiall.example)
32
- - `cosiallApiKey`: API key for the Cosiall endpoint
75
+ - `COSIALL_API_URL` base URL of your Cosiall service (e.g., https://cosiall.example)
76
+ - `COSIALL_API_KEY` API key for the Cosiall endpoint
33
77
 
34
78
  Optional
35
- - `proxyString`: `host:port` or `host:port:user:pass` (applies to HTTP(S) requests)
36
- - `logLevel`: `debug` | `info` | `warn` | `error` (default `info`)
79
+ - `LINKEDIN_PROXY_STRING` `host:port` or `host:port:user:pass`
80
+ - `LOG_LEVEL` `debug` | `info` | `warn` | `error` (default `info`)
37
81
 
38
- Defaults (can be overridden in code)
39
- - `profileCacheTtl`: 900000 ms
40
- - `searchCacheTtl`: 180000 ms
41
- - `cookieRefreshInterval`: 900000 ms
42
- - `cookieFreshnessWindow`: 86400000 ms
43
- - `maxRetries`: 2, `retryDelayMs`: 700
44
- - `accountCooldownMs`: 5000, `maxFailuresBeforeCooldown`: 3
82
+ Config defaults (override in code as needed)
83
+ - `profileCacheTtl`: 900_000 ms (15m)
84
+ - `searchCacheTtl`: 180_000 ms (3m)
85
+ - `companyCacheTtl`: 600_000 ms (10m)
86
+ - `typeaheadCacheTtl`: 3_600_000 ms (1h)
87
+ - `cookieRefreshInterval`: 900_000 ms (15m)
88
+ - `cookieFreshnessWindow`: 86_400_000 ms (24h)
89
+ - `maxRetries`: 2 (5xx retry on same account)
90
+ - `retryDelayMs`: 700 ms
91
+ - `accountCooldownMs`: 5_000 ms
92
+ - `maxFailuresBeforeCooldown`: 3
93
+ - `maxRequestHistory`: 500
45
94
 
46
- ## Initialize (run once on startup)
95
+ ## Public API (overview)
47
96
 
48
- ```ts
49
- import { initializeLinkedInClient } from 'linkedin-secret-sauce';
97
+ - Profiles
98
+ - `getProfileByVanity(vanity)` cached 15m, in‑flight de‑dupe per key.
99
+ - `getProfileByUrn(keyOrUrn)` – accepts `ACwAA...` or URNs; normalizes to key.
100
+ - `getProfilesBatch(vanities, concurrency)` – returns array aligned to inputs; `null` for failures.
50
101
 
51
- initializeLinkedInClient({
52
- cosiallApiUrl: process.env.COSIALL_API_URL!,
53
- cosiallApiKey: process.env.COSIALL_API_KEY!,
54
- proxyString: process.env.LINKEDIN_PROXY_STRING, // optional
55
- logLevel: (process.env.LOG_LEVEL as any) || 'info',
56
- });
57
- ```
102
+ - Search
103
+ - `searchSalesLeads(keywords, { start, count, decorationId })` – when options are provided returns `{ items, page }`; on HTTP 400 with `-14` decoration automatically retries with `-17`.
58
104
 
59
- ## Use the API
105
+ - Companies
106
+ - `resolveCompanyUniversalName(universalName)` → `{ companyId? }` (404 → NOT_FOUND)
107
+ - `getCompanyById(companyId)` → `Company` (10m cache)
108
+ - `getCompanyByUrl(url)` – parses numeric id or slug, then fetches
60
109
 
61
- ```ts
62
- import {
63
- getProfileByVanity,
64
- getProfileByUrn,
65
- searchSalesLeads,
66
- type LinkedInProfile,
67
- } from 'linkedin-secret-sauce';
110
+ - Typeahead
111
+ - `typeahead({ type, query, start, count })` → `TypeaheadResult` (1h cache)
112
+
113
+ - Sales Navigator profile
114
+ - `getSalesNavigatorProfileDetails(idOrUrn)` → `SalesNavigatorProfile` (404 → NOT_FOUND)
115
+
116
+ ## Errors
117
+
118
+ All errors are `LinkedInClientError` with fields `{ code, status?, accountId? }`.
119
+
120
+ Error codes (non‑exhaustive)
121
+ - `NOT_INITIALIZED`, `NO_ACCOUNTS`, `NO_VALID_ACCOUNTS`
122
+ - `MISSING_CSRF`, `REQUEST_FAILED`, `ALL_ACCOUNTS_FAILED`, `PARSE_ERROR`
123
+ - `INVALID_INPUT`, `NOT_FOUND`, `AUTH_ERROR`, `RATE_LIMITED`
68
124
 
69
- // Fetch a profile by vanity handle (cached, resilient)
70
- const profile: LinkedInProfile = await getProfileByVanity('john-doe');
125
+ ## Caching, Metrics, Proxy
71
126
 
72
- // Fetch a profile by fsdKey/URN key
73
- const profileByUrn = await getProfileByUrn('fsdKey123');
127
+ - Per‑process in‑memory caches with TTLs listed above. Expired entries are evicted on access.
128
+ - In‑flight de‑dupe for `getProfileByVanity`.
129
+ - HTTP client rotates accounts on 401/403/429 and retries 5xx on same account (`maxRetries`).
130
+ - Metrics snapshot: `import { getSnapshot }` for counters (requests, retries, cache hits, etc.).
131
+ - Request history: `import { getRequestHistory, clearRequestHistory }` for a bounded in‑memory log.
132
+ - Proxy: set `proxyString` in config (formats `host:port` or `host:port:user:pass`).
74
133
 
75
- // Search Sales Navigator leads
76
- const results = await searchSalesLeads('cto fintech');
134
+ ### Request History and Metrics (example)
135
+
136
+ ```ts
137
+ import { getRequestHistory, clearRequestHistory, getSnapshot } from 'linkedin-secret-sauce';
138
+
139
+ clearRequestHistory();
140
+ // ... make some API calls ...
141
+ console.log(getRequestHistory()[0]);
142
+ // {
143
+ // timestamp: 1730000000000,
144
+ // operation: 'getProfileByVanity',
145
+ // selector: 'https://www.linkedin.com/voyager/api/...profiles?q=memberIdentity&memberIdentity=john-doe',
146
+ // status: 200,
147
+ // durationMs: 123,
148
+ // accountId: 'acc1'
149
+ // }
150
+
151
+ console.log(getSnapshot());
152
+ // {
153
+ // profileFetches: 12,
154
+ // profileCacheHits: 34,
155
+ // inflightDedupeHits: 2,
156
+ // searchCacheHits: 5,
157
+ // typeaheadCacheHits: 3,
158
+ // companyCacheHits: 4,
159
+ // httpRetries: 3,
160
+ // httpSuccess: 21,
161
+ // httpFailures: 2,
162
+ // companyFetches: 4,
163
+ // typeaheadRequests: 5,
164
+ // requestHistorySize: 42,
165
+ // authErrors: 1,
166
+ // ...
167
+ // }
77
168
  ```
78
169
 
79
- Notes
80
- - The package manages cookies via your Cosiall endpoint; no DB needed.
81
- - In-memory caches reset on process restart (safe: cookies refresh automatically).
82
- - Logs are JSON and respect `logLevel`; sensitive fields (e.g., `cosiallApiKey`) are masked.
170
+ ### Accounts Summary
83
171
 
84
- ## Common commands
172
+ ```ts
173
+ import { getAccountsSummary } from 'linkedin-secret-sauce';
174
+
175
+ const accounts = getAccountsSummary();
176
+ // [
177
+ // {
178
+ // accountId: 'acc1',
179
+ // healthy: false,
180
+ // failures: 1,
181
+ // cooldownUntil: 1730005000000,
182
+ // cooldownRemainingMs: 4200,
183
+ // expiresAt: 1730010000, // epoch seconds (if provided by Cosiall)
184
+ // lastUsedAt: 1730004000123
185
+ // },
186
+ // {
187
+ // accountId: 'acc2',
188
+ // healthy: true,
189
+ // failures: 0,
190
+ // cooldownUntil: 0,
191
+ // cooldownRemainingMs: 0,
192
+ // expiresAt: 1730010000,
193
+ // lastUsedAt: 1730003999000
194
+ // }
195
+ // ]
196
+ ```
85
197
 
86
- ```bash
87
- # build JS + .d.ts to dist/
88
- pnpm build
198
+ ## Testing
89
199
 
90
- # run tests
200
+ ```bash
91
201
  pnpm test
92
-
93
- # typecheck only (no emit)
94
202
  pnpm exec tsc -p tsconfig.json --noEmit
95
203
  ```
204
+
205
+ ## Manual Publish (npm)
206
+
207
+ - Pre-checks:
208
+ - Working tree is clean; tests pass; `pnpm build` produced `dist/`.
209
+ - `publishConfig.registry` points to `https://registry.npmjs.org/` (already set).
210
+ - No repo `.npmrc` that overrides registry to GitHub Packages.
211
+
212
+ - Steps (local):
213
+ 1) `npm login` (once per machine; ensure correct org/user)
214
+ 2) `npm whoami` (sanity check)
215
+ 3) Bump version: `npm version patch|minor|major`
216
+ 4) Publish: `npm publish`
217
+ 5) Push tags: `git push --follow-tags`
218
+
219
+ Notes:
220
+ - If your npm account enforces 2FA for publish, you’ll be prompted for an OTP.
221
+ - For scoped packages that should be public, add `--access public` (not needed here because the package name is unscoped).
222
+ - The “Publish your first package” button on GitHub targets GitHub Packages (`npm publish --registry=https://npm.pkg.github.com/`), not npmjs.com.
223
+
224
+
225
+ ## Playground (React + Vite)\n\nDev-only UI to exercise the library safely via a local server.\n\n- Start both server and client: \n - pnpm dev:playground\n- Or separately: \n - pnpm -C apps/playground run server (http://localhost:5175)\n - pnpm -C apps/playground run client (http://localhost:5173)\n\nThe server initializes the library and exposes endpoints for profiles, companies, typeahead, sales search, and sales profile.\nMetrics and request history are exposed at /api/metrics and /api/history.\n\nBy default the server uses a dev stub for cookies at /api/flexiq/linkedin-cookies/all.\nTo use your Cosiall backend, set env vars: COSIALL_API_URL and COSIALL_API_KEY and remove the stub in pps/playground/server/index.ts.\n
226
+ ## Playground (local UI)
227
+
228
+ See `docs/PLAYGROUND.md` for usage, ports, and tips.
package/dist/config.d.ts CHANGED
@@ -5,12 +5,15 @@ export interface LinkedInClientConfig {
5
5
  logLevel?: 'debug' | 'info' | 'warn' | 'error';
6
6
  profileCacheTtl?: number;
7
7
  searchCacheTtl?: number;
8
+ companyCacheTtl?: number;
9
+ typeaheadCacheTtl?: number;
8
10
  cookieRefreshInterval?: number;
9
11
  cookieFreshnessWindow?: number;
10
12
  maxRetries?: number;
11
13
  retryDelayMs?: number;
12
14
  accountCooldownMs?: number;
13
15
  maxFailuresBeforeCooldown?: number;
16
+ maxRequestHistory?: number;
14
17
  }
15
18
  export declare function initializeLinkedInClient(config: LinkedInClientConfig): void;
16
19
  export declare function getConfig(): LinkedInClientConfig;
package/dist/config.js CHANGED
@@ -21,6 +21,8 @@ function initializeLinkedInClient(config) {
21
21
  const withDefaults = {
22
22
  profileCacheTtl: 15 * 60 * 1000,
23
23
  searchCacheTtl: 3 * 60 * 1000,
24
+ companyCacheTtl: 10 * 60 * 1000, // 600_000 ms
25
+ typeaheadCacheTtl: 60 * 60 * 1000, // 3_600_000 ms
24
26
  cookieRefreshInterval: 15 * 60 * 1000,
25
27
  cookieFreshnessWindow: 24 * 60 * 60 * 1000,
26
28
  maxRetries: 2,
@@ -28,6 +30,7 @@ function initializeLinkedInClient(config) {
28
30
  accountCooldownMs: 5000,
29
31
  maxFailuresBeforeCooldown: 3,
30
32
  logLevel: 'info',
33
+ maxRequestHistory: 500,
31
34
  ...config,
32
35
  };
33
36
  // Optionally mask API key from accidental JSON serialization
@@ -42,7 +45,10 @@ function initializeLinkedInClient(config) {
42
45
  catch {
43
46
  // ignore if defineProperty fails in exotic environments
44
47
  }
45
- globalConfig = withDefaults;
48
+ // Idempotent: only set once; subsequent calls are no-ops
49
+ if (!globalConfig) {
50
+ globalConfig = withDefaults;
51
+ }
46
52
  }
47
53
  function getConfig() {
48
54
  if (!globalConfig) {
@@ -3,7 +3,18 @@ export declare function selectAccountForRequest(): Promise<{
3
3
  accountId: string;
4
4
  cookies: LinkedInCookie[];
5
5
  }>;
6
+ export declare function getAccountsSummary(): Array<{
7
+ accountId: string;
8
+ healthy: boolean;
9
+ failures: number;
10
+ cooldownUntil: number;
11
+ cooldownRemainingMs: number;
12
+ expiresAt?: number;
13
+ lastUsedAt?: number;
14
+ }>;
6
15
  export declare function reportAccountFailure(accountId: string, isAuthError: boolean): void;
7
16
  export declare function reportAccountSuccess(accountId: string): void;
8
17
  export declare function buildCookieHeader(cookies: LinkedInCookie[]): string;
9
18
  export declare function extractCsrfToken(cookies: LinkedInCookie[]): string;
19
+ export declare function adminSetCooldown(accountId: string, ms: number): void;
20
+ export declare function adminResetAccount(accountId: string): void;
@@ -1,10 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.selectAccountForRequest = selectAccountForRequest;
4
+ exports.getAccountsSummary = getAccountsSummary;
4
5
  exports.reportAccountFailure = reportAccountFailure;
5
6
  exports.reportAccountSuccess = reportAccountSuccess;
6
7
  exports.buildCookieHeader = buildCookieHeader;
7
8
  exports.extractCsrfToken = extractCsrfToken;
9
+ exports.adminSetCooldown = adminSetCooldown;
10
+ exports.adminResetAccount = adminResetAccount;
8
11
  const cosiall_client_1 = require("./cosiall-client");
9
12
  const config_1 = require("./config");
10
13
  const errors_1 = require("./utils/errors");
@@ -23,11 +26,21 @@ function nowMs() {
23
26
  function nowSec() {
24
27
  return Math.floor(Date.now() / 1000);
25
28
  }
29
+ function toEpochSeconds(v) {
30
+ if (typeof v !== 'number')
31
+ return undefined;
32
+ if (!Number.isFinite(v) || v <= 0)
33
+ return undefined;
34
+ // Treat large values as ms
35
+ return v > 1e10 ? Math.floor(v / 1000) : Math.floor(v);
36
+ }
26
37
  function isExpired(entry) {
27
- const jsession = entry.cookies.find(c => c.name.toUpperCase() === 'JSESSIONID');
28
- if (typeof jsession?.expires === 'number' && jsession.expires < nowSec())
38
+ const jsession = entry.cookies.find(c => String(c.name).toUpperCase() === 'JSESSIONID');
39
+ const jsExp = toEpochSeconds(jsession?.expires);
40
+ if (jsExp && jsExp < nowSec())
29
41
  return true;
30
- if (typeof entry.expiresAt === 'number' && entry.expiresAt < nowSec())
42
+ const accExp = toEpochSeconds(entry.expiresAt);
43
+ if (accExp && accExp < nowSec())
31
44
  return true;
32
45
  return false;
33
46
  }
@@ -44,6 +57,7 @@ async function ensureInitialized() {
44
57
  expiresAt: acc.expiresAt,
45
58
  failures: 0,
46
59
  cooldownUntil: 0,
60
+ lastUsedAt: undefined,
47
61
  });
48
62
  poolState.order.push(acc.accountId);
49
63
  }
@@ -66,6 +80,7 @@ async function ensureInitialized() {
66
80
  expiresAt: acc.expiresAt,
67
81
  failures: poolState.accounts.get(acc.accountId)?.failures ?? 0,
68
82
  cooldownUntil: poolState.accounts.get(acc.accountId)?.cooldownUntil ?? 0,
83
+ lastUsedAt: poolState.accounts.get(acc.accountId)?.lastUsedAt,
69
84
  });
70
85
  newOrder.push(acc.accountId);
71
86
  }
@@ -99,10 +114,33 @@ async function selectAccountForRequest() {
99
114
  poolState.rrIndex = (idx + 1) % n;
100
115
  (0, logger_1.log)('debug', 'cookiePool.select', { accountId: entry.accountId, rrIndex: poolState.rrIndex });
101
116
  (0, metrics_1.incrementMetric)('accountSelections');
117
+ entry.lastUsedAt = nowMs();
102
118
  return { accountId: entry.accountId, cookies: entry.cookies };
103
119
  }
104
120
  throw new errors_1.LinkedInClientError('No valid LinkedIn accounts', 'NO_VALID_ACCOUNTS', 503);
105
121
  }
122
+ function getAccountsSummary() {
123
+ const now = nowMs();
124
+ const out = [];
125
+ for (const id of poolState.order) {
126
+ const entry = poolState.accounts.get(id);
127
+ if (!entry)
128
+ continue;
129
+ const expired = isExpired(entry);
130
+ const cooling = entry.cooldownUntil > now;
131
+ const healthy = !expired && !cooling && entry.failures === 0;
132
+ out.push({
133
+ accountId: entry.accountId,
134
+ healthy,
135
+ failures: entry.failures,
136
+ cooldownUntil: entry.cooldownUntil,
137
+ cooldownRemainingMs: Math.max(0, entry.cooldownUntil - now),
138
+ expiresAt: entry.expiresAt,
139
+ lastUsedAt: entry.lastUsedAt,
140
+ });
141
+ }
142
+ return out;
143
+ }
106
144
  function reportAccountFailure(accountId, isAuthError) {
107
145
  const entry = poolState.accounts.get(accountId);
108
146
  if (!entry)
@@ -145,3 +183,17 @@ function getSettings() {
145
183
  return { cooldownMs: 5000, maxFailures: 3 };
146
184
  }
147
185
  }
186
+ // Admin/dev helpers (no-ops if account missing). Safe to export for playground tooling.
187
+ function adminSetCooldown(accountId, ms) {
188
+ const entry = poolState.accounts.get(accountId);
189
+ if (!entry)
190
+ return;
191
+ entry.cooldownUntil = nowMs() + Math.max(0, Number(ms || 0));
192
+ }
193
+ function adminResetAccount(accountId) {
194
+ const entry = poolState.accounts.get(accountId);
195
+ if (!entry)
196
+ return;
197
+ entry.failures = 0;
198
+ entry.cooldownUntil = 0;
199
+ }
@@ -6,6 +6,8 @@ const cookie_pool_1 = require("./cookie-pool");
6
6
  const errors_1 = require("./utils/errors");
7
7
  const logger_1 = require("./utils/logger");
8
8
  const metrics_1 = require("./utils/metrics");
9
+ const request_history_1 = require("./utils/request-history");
10
+ const linkedin_config_1 = require("./utils/linkedin-config");
9
11
  function sleep(ms) {
10
12
  return new Promise((resolve) => setTimeout(resolve, ms));
11
13
  }
@@ -14,6 +16,7 @@ function isRetryableStatus(status) {
14
16
  }
15
17
  async function executeLinkedInRequest(options, _operationName) {
16
18
  const config = (0, config_1.getConfig)();
19
+ const op = _operationName || 'request';
17
20
  const perAccountAttempts = (config.maxRetries ?? 0) + 1;
18
21
  const delay = config.retryDelayMs ?? 700;
19
22
  // Setup proxy env if configured
@@ -22,13 +25,11 @@ async function executeLinkedInRequest(options, _operationName) {
22
25
  let proxySet = false;
23
26
  try {
24
27
  if (config.proxyString) {
25
- const [host, port, user, pass] = config.proxyString.split(":");
26
- const proxyUrl = user && pass
27
- ? `http://${user}:${pass}@${host}:${port}`
28
- : `http://${host}:${port}`;
28
+ const { url: proxyUrl, masked } = parseProxyString(config.proxyString);
29
29
  process.env.HTTP_PROXY = proxyUrl;
30
30
  process.env.HTTPS_PROXY = proxyUrl;
31
31
  proxySet = true;
32
+ (0, logger_1.log)('info', 'proxy.set', { host: masked });
32
33
  }
33
34
  // Try up to 3 different accounts on auth/rate failures
34
35
  let lastError;
@@ -55,18 +56,38 @@ async function executeLinkedInRequest(options, _operationName) {
55
56
  const { accountId, cookies } = selection;
56
57
  const csrf = (0, cookie_pool_1.extractCsrfToken)(cookies);
57
58
  const cookieHeader = (0, cookie_pool_1.buildCookieHeader)(cookies);
59
+ // Choose header profile based on Sales vs Voyager context
60
+ const isSales = /\/sales\//i.test(String(options.headers?.Referer || ''))
61
+ || /https:\/\/www\.linkedin\.com\/sales-api/i.test(options.url);
62
+ const baseHeaders = isSales
63
+ ? (0, linkedin_config_1.buildSalesHeaders)({ cookiesHeader: cookieHeader, csrf, referer: options.headers?.Referer })
64
+ : (0, linkedin_config_1.buildVoyagerHeaders)({ cookiesHeader: cookieHeader, csrf, referer: options.headers?.Referer });
58
65
  const headers = {
59
- Cookie: cookieHeader,
60
- 'csrf-token': csrf,
66
+ ...baseHeaders,
61
67
  ...(options.headers || {}),
62
68
  };
63
69
  for (let attempt = 0; attempt < perAccountAttempts; attempt++) {
64
- (0, logger_1.log)('debug', 'http.attempt', { accountId, attempt: attempt + 1, url: options.url });
65
- const res = await fetch(options.url, {
66
- method: options.method ?? 'GET',
67
- headers,
68
- body: options.body ? JSON.stringify(options.body) : undefined,
69
- });
70
+ const started = Date.now();
71
+ (0, logger_1.log)('debug', 'http.attempt', { accountId, attempt: attempt + 1, method: options.method ?? 'GET', url: options.url });
72
+ let res;
73
+ try {
74
+ res = await fetch(options.url, {
75
+ method: options.method ?? 'GET',
76
+ headers,
77
+ body: options.body ? JSON.stringify(options.body) : undefined,
78
+ });
79
+ }
80
+ catch (err) {
81
+ const code = err?.code || err?.cause?.code || 'FETCH_FAILED';
82
+ (0, logger_1.log)('error', 'http.networkError', { accountId, url: options.url, code, message: String(err?.message || err) });
83
+ (0, metrics_1.incrementMetric)('httpFailures');
84
+ try {
85
+ (0, request_history_1.recordRequest)({ operation: op, selector: options.url, status: 0, durationMs: Date.now() - started, accountId, errorMessage: String(err?.message || err) });
86
+ }
87
+ catch { }
88
+ lastError = new errors_1.LinkedInClientError('LinkedIn fetch failed', 'REQUEST_FAILED', 0, accountId);
89
+ break; // rotate to next account
90
+ }
70
91
  if (res.ok) {
71
92
  // success
72
93
  try {
@@ -75,6 +96,16 @@ async function executeLinkedInRequest(options, _operationName) {
75
96
  catch { }
76
97
  (0, logger_1.log)('info', 'http.success', { accountId, url: options.url });
77
98
  (0, metrics_1.incrementMetric)('httpSuccess');
99
+ try {
100
+ (0, request_history_1.recordRequest)({
101
+ operation: op,
102
+ selector: options.url,
103
+ status: res.status ?? 200,
104
+ durationMs: Date.now() - started,
105
+ accountId,
106
+ });
107
+ }
108
+ catch { }
78
109
  return (await res.json());
79
110
  }
80
111
  const status = res.status ?? 0;
@@ -86,18 +117,44 @@ async function executeLinkedInRequest(options, _operationName) {
86
117
  catch { }
87
118
  (0, logger_1.log)('warn', 'http.rotateOnAuth', { accountId, status });
88
119
  (0, metrics_1.incrementMetric)('authErrors');
120
+ (0, metrics_1.incrementMetric)('httpFailures');
89
121
  lastError = new errors_1.LinkedInClientError(`LinkedIn request failed: ${status}`, 'REQUEST_FAILED', status, accountId);
122
+ try {
123
+ (0, request_history_1.recordRequest)({
124
+ operation: op,
125
+ selector: options.url,
126
+ status,
127
+ durationMs: Date.now() - started,
128
+ accountId,
129
+ errorMessage: 'rotate',
130
+ });
131
+ }
132
+ catch { }
90
133
  break; // break inner loop to rotate account
91
134
  }
92
135
  // Retryable 5xx on same account
93
136
  if ((status >= 500 && status < 600) && attempt < perAccountAttempts - 1) {
94
- (0, logger_1.log)('debug', 'http.retry', { accountId, status, nextDelayMs: delay });
137
+ (0, logger_1.log)('debug', 'http.retry', { accountId, status, attempt: attempt + 1, nextDelayMs: delay });
95
138
  (0, metrics_1.incrementMetric)('httpRetries');
96
139
  await sleep(delay);
97
140
  continue;
98
141
  }
99
142
  // Non-retryable: throw
100
- throw new errors_1.LinkedInClientError(`LinkedIn request failed: ${status}`, 'REQUEST_FAILED', status, accountId);
143
+ const err = new errors_1.LinkedInClientError(`LinkedIn request failed: ${status}`, 'REQUEST_FAILED', status, accountId);
144
+ (0, metrics_1.incrementMetric)('httpFailures');
145
+ (0, logger_1.log)(status >= 500 ? 'warn' : 'error', 'http.fail', { accountId, status, url: options.url });
146
+ try {
147
+ (0, request_history_1.recordRequest)({
148
+ operation: op,
149
+ selector: options.url,
150
+ status,
151
+ durationMs: Date.now() - started,
152
+ accountId,
153
+ errorMessage: err.message,
154
+ });
155
+ }
156
+ catch { }
157
+ throw err;
101
158
  }
102
159
  }
103
160
  // Exhausted rotations
@@ -116,6 +173,51 @@ async function executeLinkedInRequest(options, _operationName) {
116
173
  delete process.env.HTTPS_PROXY;
117
174
  else
118
175
  process.env.HTTPS_PROXY = prevHttps;
176
+ (0, logger_1.log)('info', 'proxy.restore', {});
119
177
  }
120
178
  }
121
179
  }
180
+ // Parse proxy string supporting:
181
+ // - Full URL forms: http(s)://[user[:pass]@]host:port (host may be [ipv6])
182
+ // - Colon-delimited: host:port[:user:pass] with optional [ipv6]
183
+ function parseProxyString(input) {
184
+ const s = String(input).trim();
185
+ // URL form
186
+ if (/^\w+:\/\//.test(s)) {
187
+ try {
188
+ const u = new URL(s);
189
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
190
+ throw new errors_1.LinkedInClientError(`Unsupported proxy protocol: ${u.protocol}`, 'INVALID_CONFIG', 400);
191
+ }
192
+ const user = u.username || '';
193
+ const pass = u.password || '';
194
+ const hostport = u.host; // includes brackets for IPv6
195
+ const url = `${u.protocol}//${user ? `${user}:${pass}@` : ''}${hostport}`;
196
+ const masked = user ? `${hostport}:${user}:***` : hostport;
197
+ return { url, masked };
198
+ }
199
+ catch {
200
+ // fall through to colon parsing
201
+ }
202
+ }
203
+ // Colon-delimited
204
+ // [ipv6]:port[:user:pass] OR host:port[:user:pass]
205
+ const m = s.match(/^(\[?[^\]]+\]?):(\d+)(?::([^:]*):(.+))?$/);
206
+ if (m) {
207
+ let host = m[1];
208
+ const port = m[2];
209
+ const user = m[3];
210
+ const pass = m[4];
211
+ // Ensure brackets for IPv6 in URL
212
+ const needsBrackets = host.includes(':') && !/^\[.*\]$/.test(host);
213
+ if (needsBrackets)
214
+ host = `[${host}]`;
215
+ const auth = user && pass ? `${user}:${pass}@` : '';
216
+ const url = `http://${auth}${host}:${port}`;
217
+ const hostport = `${host}:${port}`;
218
+ const masked = user && pass ? `${hostport}:${user}:***` : hostport;
219
+ return { url, masked };
220
+ }
221
+ // Fallback: conservative
222
+ return { url: s.includes('://') ? s : `http://${s}`, masked: '***' };
223
+ }
package/dist/index.d.ts CHANGED
@@ -8,3 +8,5 @@ export { parseSalesSearchResults } from './parsers/search-parser';
8
8
  export * from './linkedin-api';
9
9
  export * from './types';
10
10
  export * from './utils/metrics';
11
+ export { getRequestHistory, clearRequestHistory } from './utils/request-history';
12
+ export type { RequestHistoryEntry } from './utils/request-history';