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 +193 -60
- package/dist/config.d.ts +3 -0
- package/dist/config.js +7 -1
- package/dist/cookie-pool.d.ts +11 -0
- package/dist/cookie-pool.js +55 -3
- package/dist/http-client.js +116 -14
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -1
- package/dist/linkedin-api.d.ts +22 -3
- package/dist/linkedin-api.js +366 -28
- package/dist/parsers/company-parser.d.ts +2 -0
- package/dist/parsers/company-parser.js +25 -0
- package/dist/parsers/image-parser.d.ts +1 -0
- package/dist/parsers/image-parser.js +15 -0
- package/dist/parsers/profile-parser.js +48 -7
- package/dist/parsers/search-parser.js +12 -3
- package/dist/types.d.ts +147 -0
- package/dist/utils/errors.d.ts +4 -0
- package/dist/utils/errors.js +5 -0
- package/dist/utils/linkedin-config.d.ts +43 -0
- package/dist/utils/linkedin-config.js +77 -0
- package/dist/utils/metrics.d.ts +5 -0
- package/dist/utils/metrics.js +16 -1
- package/dist/utils/request-history.d.ts +12 -0
- package/dist/utils/request-history.js +45 -0
- package/dist/utils/search-encoder.d.ts +4 -0
- package/dist/utils/search-encoder.js +163 -0
- package/package.json +16 -6
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
|
|
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`
|
|
7
|
-
- Test: `pnpm 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
|
-
#
|
|
23
|
+
# npm
|
|
24
|
+
npm install linkedin-secret-sauce
|
|
18
25
|
```
|
|
19
26
|
|
|
20
|
-
|
|
27
|
+
## Quick Start
|
|
21
28
|
|
|
22
|
-
```
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- `
|
|
32
|
-
- `
|
|
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
|
-
- `
|
|
36
|
-
- `
|
|
79
|
+
- `LINKEDIN_PROXY_STRING` – `host:port` or `host:port:user:pass`
|
|
80
|
+
- `LOG_LEVEL` – `debug` | `info` | `warn` | `error` (default `info`)
|
|
37
81
|
|
|
38
|
-
|
|
39
|
-
- `profileCacheTtl`:
|
|
40
|
-
- `searchCacheTtl`:
|
|
41
|
-
- `
|
|
42
|
-
- `
|
|
43
|
-
- `
|
|
44
|
-
- `
|
|
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
|
-
##
|
|
95
|
+
## Public API (overview)
|
|
47
96
|
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
const profile: LinkedInProfile = await getProfileByVanity('john-doe');
|
|
125
|
+
## Caching, Metrics, Proxy
|
|
71
126
|
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
# build JS + .d.ts to dist/
|
|
88
|
-
pnpm build
|
|
198
|
+
## Testing
|
|
89
199
|
|
|
90
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/cookie-pool.d.ts
CHANGED
|
@@ -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;
|
package/dist/cookie-pool.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/http-client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
60
|
-
'csrf-token': csrf,
|
|
66
|
+
...baseHeaders,
|
|
61
67
|
...(options.headers || {}),
|
|
62
68
|
};
|
|
63
69
|
for (let attempt = 0; attempt < perAccountAttempts; attempt++) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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';
|