salesprompter-cli 0.1.19 → 0.1.20
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 +53 -24
- package/dist/cli.js +1431 -127
- package/dist/linkedin-products.js +29 -8
- package/dist/linkedin-session.js +751 -0
- package/dist/sales-navigator.js +207 -36
- package/dist/salesnav-backfill.js +710 -0
- package/package.json +4 -1
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createClient } from "@supabase/supabase-js";
|
|
6
|
+
const COOKIE_VAULT_KEY_CONTEXT = "salesprompter:linkedin-session-cookie:v1";
|
|
7
|
+
const AES_256_KEY_BYTES = 32;
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
9
|
+
const DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
|
|
10
|
+
const MAX_COOKIE_VALIDATION_ATTEMPTS = 8;
|
|
11
|
+
const COOKIE_VALIDATION_TIMEOUT_MS = 75_000;
|
|
12
|
+
const COOKIE_PROBE_TIMEOUT_MS = 8_000;
|
|
13
|
+
const DEFAULT_EXCLUDED_LINKEDIN_SESSION_USER_EMAILS = ["hello@danielsinewe.com"];
|
|
14
|
+
const DEFAULT_EXCLUDED_LINKEDIN_SESSION_USER_HANDLES = ["danielsinewe"];
|
|
15
|
+
const ACTIVE_LINKEDIN_SESSION_LOOKBACK_HOURS = 72;
|
|
16
|
+
const MAX_RECOVERABLE_LINKEDIN_SESSION_CANDIDATES = 12;
|
|
17
|
+
const RECOVERABLE_LINKEDIN_SESSION_INACTIVE_REASON_PREFIX = "quarantined_browser_unverified";
|
|
18
|
+
const ENV_FILE_PATHS = [
|
|
19
|
+
resolve(dirname(fileURLToPath(import.meta.url)), "..", ".env.local"),
|
|
20
|
+
resolve(dirname(fileURLToPath(import.meta.url)), "..", ".env"),
|
|
21
|
+
resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "salesprompter-app", ".env.local"),
|
|
22
|
+
resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "salesprompter-app", ".env.vercel.local")
|
|
23
|
+
];
|
|
24
|
+
const envFileCache = new Map();
|
|
25
|
+
function normalizeSessionCookie(sessionCookie) {
|
|
26
|
+
const trimmed = sessionCookie?.trim() || "";
|
|
27
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
28
|
+
}
|
|
29
|
+
function normalizeIdentityValue(value) {
|
|
30
|
+
const trimmed = value?.trim().toLowerCase() || "";
|
|
31
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
32
|
+
}
|
|
33
|
+
function dedupeIdentityValues(values) {
|
|
34
|
+
return Array.from(new Set(values
|
|
35
|
+
.map((value) => normalizeIdentityValue(value))
|
|
36
|
+
.filter((value) => Boolean(value))));
|
|
37
|
+
}
|
|
38
|
+
function parseIdentityEnvList(value) {
|
|
39
|
+
return dedupeIdentityValues((value || "").split(","));
|
|
40
|
+
}
|
|
41
|
+
function parseDotEnvFile(filePath) {
|
|
42
|
+
if (envFileCache.has(filePath)) {
|
|
43
|
+
return envFileCache.get(filePath);
|
|
44
|
+
}
|
|
45
|
+
const parsed = new Map();
|
|
46
|
+
if (existsSync(filePath)) {
|
|
47
|
+
const content = readFileSync(filePath, "utf8");
|
|
48
|
+
for (const line of content.split(/\r?\n/)) {
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
if (trimmed.length === 0 || trimmed.startsWith("#")) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const separatorIndex = trimmed.indexOf("=");
|
|
54
|
+
if (separatorIndex <= 0) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const key = trimmed.slice(0, separatorIndex).trim();
|
|
58
|
+
let value = trimmed.slice(separatorIndex + 1).trim();
|
|
59
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
60
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
61
|
+
value = value.slice(1, -1);
|
|
62
|
+
}
|
|
63
|
+
if (key.length > 0 && value.length > 0) {
|
|
64
|
+
parsed.set(key, value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
envFileCache.set(filePath, parsed);
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
function resolveConfiguredEnvValue(env, key) {
|
|
72
|
+
const directValue = env[key]?.trim() || "";
|
|
73
|
+
if (directValue.length > 0) {
|
|
74
|
+
return directValue;
|
|
75
|
+
}
|
|
76
|
+
if (env !== process.env) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
for (const filePath of ENV_FILE_PATHS) {
|
|
80
|
+
const parsed = parseDotEnvFile(filePath);
|
|
81
|
+
const value = parsed.get(key)?.trim() || "";
|
|
82
|
+
if (value.length > 0) {
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
function isTruthySetting(value) {
|
|
89
|
+
return ["1", "true", "on", "yes"].includes(value?.trim().toLowerCase() || "");
|
|
90
|
+
}
|
|
91
|
+
function isFalsySetting(value) {
|
|
92
|
+
return ["0", "false", "off", "no"].includes(value?.trim().toLowerCase() || "");
|
|
93
|
+
}
|
|
94
|
+
export function resolveCliLinkedInSessionExclusions(env = process.env) {
|
|
95
|
+
return {
|
|
96
|
+
userEmails: dedupeIdentityValues([
|
|
97
|
+
...DEFAULT_EXCLUDED_LINKEDIN_SESSION_USER_EMAILS,
|
|
98
|
+
...parseIdentityEnvList(resolveConfiguredEnvValue(env, "SALESPROMPTER_LINKEDIN_SESSION_EXCLUDED_EMAILS"))
|
|
99
|
+
]),
|
|
100
|
+
userHandles: dedupeIdentityValues([
|
|
101
|
+
...DEFAULT_EXCLUDED_LINKEDIN_SESSION_USER_HANDLES,
|
|
102
|
+
...parseIdentityEnvList(resolveConfiguredEnvValue(env, "SALESPROMPTER_LINKEDIN_SESSION_EXCLUDED_HANDLES"))
|
|
103
|
+
])
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function isExcludedLinkedInSessionIdentity(identity, exclusions) {
|
|
107
|
+
const normalizedEmail = normalizeIdentityValue(identity.userEmail);
|
|
108
|
+
if (normalizedEmail && exclusions.userEmails.includes(normalizedEmail)) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
const normalizedHandle = normalizeIdentityValue(identity.userHandle);
|
|
112
|
+
return Boolean(normalizedHandle && exclusions.userHandles.includes(normalizedHandle));
|
|
113
|
+
}
|
|
114
|
+
function getCookieVaultKey(env = process.env) {
|
|
115
|
+
const secret = resolveConfiguredEnvValue(env, "LINKEDIN_SESSION_COOKIE_ENCRYPTION_KEY") ||
|
|
116
|
+
resolveConfiguredEnvValue(env, "EXTENSION_AUTH_SECRET") ||
|
|
117
|
+
resolveConfiguredEnvValue(env, "CLERK_SECRET_KEY") ||
|
|
118
|
+
null;
|
|
119
|
+
if (!secret) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return createHash("sha256")
|
|
123
|
+
.update(`${COOKIE_VAULT_KEY_CONTEXT}:${secret}`)
|
|
124
|
+
.digest()
|
|
125
|
+
.subarray(0, AES_256_KEY_BYTES);
|
|
126
|
+
}
|
|
127
|
+
export function sessionCookieHash(sessionCookie) {
|
|
128
|
+
const normalized = normalizeSessionCookie(sessionCookie);
|
|
129
|
+
if (!normalized) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return createHash("sha256").update(normalized).digest("hex");
|
|
133
|
+
}
|
|
134
|
+
export function encryptLinkedInSessionCookie(sessionCookie, env = process.env) {
|
|
135
|
+
const normalized = normalizeSessionCookie(sessionCookie);
|
|
136
|
+
const key = getCookieVaultKey(env);
|
|
137
|
+
if (!normalized || !key) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const iv = randomBytes(12);
|
|
141
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
142
|
+
const ciphertext = Buffer.concat([cipher.update(normalized, "utf8"), cipher.final()]);
|
|
143
|
+
const authTag = cipher.getAuthTag();
|
|
144
|
+
return {
|
|
145
|
+
sessionCookieSha256: sessionCookieHash(normalized),
|
|
146
|
+
sessionCookieCiphertext: ciphertext.toString("base64url"),
|
|
147
|
+
sessionCookieIv: iv.toString("base64url"),
|
|
148
|
+
sessionCookieAuthTag: authTag.toString("base64url")
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export function decryptLinkedInSessionCookie(record, env = process.env) {
|
|
152
|
+
if (!record) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const key = getCookieVaultKey(env);
|
|
156
|
+
if (!key) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(record.sessionCookieIv, "base64url"));
|
|
160
|
+
decipher.setAuthTag(Buffer.from(record.sessionCookieAuthTag, "base64url"));
|
|
161
|
+
const plaintext = Buffer.concat([
|
|
162
|
+
decipher.update(Buffer.from(record.sessionCookieCiphertext, "base64url")),
|
|
163
|
+
decipher.final()
|
|
164
|
+
]).toString("utf8");
|
|
165
|
+
return normalizeSessionCookie(plaintext);
|
|
166
|
+
}
|
|
167
|
+
export function resolveCliLinkedInSessionConfig(env = process.env) {
|
|
168
|
+
const manageSessionsSetting = resolveConfiguredEnvValue(env, "SALESPROMPTER_CLI_MANAGE_LINKEDIN_SESSIONS");
|
|
169
|
+
const explicitEnable = isTruthySetting(manageSessionsSetting);
|
|
170
|
+
const explicitDisable = isFalsySetting(manageSessionsSetting);
|
|
171
|
+
const autoEnable = !manageSessionsSetting;
|
|
172
|
+
if (explicitDisable || (!explicitEnable && !autoEnable)) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const supabaseUrl = resolveConfiguredEnvValue(env, "SALESPROMPTER_SUPABASE_URL") ||
|
|
176
|
+
resolveConfiguredEnvValue(env, "NEXT_PUBLIC_SUPABASE_URL") ||
|
|
177
|
+
"";
|
|
178
|
+
const supabaseServiceRoleKey = resolveConfiguredEnvValue(env, "SUPABASE_SERVICE_ROLE_KEY") || "";
|
|
179
|
+
const hasVaultKey = Boolean(getCookieVaultKey(env));
|
|
180
|
+
const missing = [];
|
|
181
|
+
if (!supabaseUrl) {
|
|
182
|
+
missing.push("SALESPROMPTER_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL");
|
|
183
|
+
}
|
|
184
|
+
if (!supabaseServiceRoleKey) {
|
|
185
|
+
missing.push("SUPABASE_SERVICE_ROLE_KEY");
|
|
186
|
+
}
|
|
187
|
+
if (!hasVaultKey) {
|
|
188
|
+
missing.push("LINKEDIN_SESSION_COOKIE_ENCRYPTION_KEY or EXTENSION_AUTH_SECRET or CLERK_SECRET_KEY");
|
|
189
|
+
}
|
|
190
|
+
if (missing.length > 0) {
|
|
191
|
+
if (!explicitEnable) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
throw new Error(`CLI-managed LinkedIn session rotation is enabled, but required configuration is missing: ${missing.join(", ")}. Disable SALESPROMPTER_CLI_MANAGE_LINKEDIN_SESSIONS to use the app-managed export path instead.`);
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
supabaseUrl,
|
|
198
|
+
supabaseServiceRoleKey
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
export function createLinkedInSessionSupabaseClient(env = process.env) {
|
|
202
|
+
const config = resolveCliLinkedInSessionConfig(env);
|
|
203
|
+
if (!config) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
|
|
207
|
+
auth: { persistSession: false }
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
export async function recordLinkedInSessionCookieAudit(supabase, input) {
|
|
211
|
+
const checkedAt = input.checkedAt || new Date().toISOString();
|
|
212
|
+
const updateResult = await supabase
|
|
213
|
+
.from("linkedin_session_cookies")
|
|
214
|
+
.update({
|
|
215
|
+
is_active: input.isActive,
|
|
216
|
+
inactive_reason: input.inactiveReason ?? null,
|
|
217
|
+
last_validation_at: checkedAt,
|
|
218
|
+
last_validation_status: input.isActive ? "active" : "inactive",
|
|
219
|
+
last_feed_status: input.feedStatus,
|
|
220
|
+
last_sales_navigator_status: input.salesNavigatorStatus,
|
|
221
|
+
last_recruiter_status: input.recruiterStatus,
|
|
222
|
+
last_product_class: input.productClass,
|
|
223
|
+
last_validation_error: input.validationError ?? null,
|
|
224
|
+
last_validation_details: input.details ?? {}
|
|
225
|
+
})
|
|
226
|
+
.eq("session_cookie_sha256", input.sessionCookieSha256);
|
|
227
|
+
if (updateResult.error) {
|
|
228
|
+
throw new Error(`Failed to update LinkedIn session cookie status: ${updateResult.error.message}`);
|
|
229
|
+
}
|
|
230
|
+
const insertResult = await supabase.from("linkedin_session_cookie_checks").insert({
|
|
231
|
+
session_cookie_sha256: input.sessionCookieSha256,
|
|
232
|
+
checked_at: checkedAt,
|
|
233
|
+
source: input.source,
|
|
234
|
+
is_active: input.isActive,
|
|
235
|
+
inactive_reason: input.inactiveReason ?? null,
|
|
236
|
+
feed_status: input.feedStatus,
|
|
237
|
+
sales_navigator_status: input.salesNavigatorStatus,
|
|
238
|
+
recruiter_status: input.recruiterStatus,
|
|
239
|
+
product_class: input.productClass,
|
|
240
|
+
validation_error: input.validationError ?? null,
|
|
241
|
+
details: input.details ?? {}
|
|
242
|
+
});
|
|
243
|
+
if (insertResult.error) {
|
|
244
|
+
throw new Error(`Failed to insert LinkedIn session cookie audit row: ${insertResult.error.message}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function lookbackThresholdIso(hours) {
|
|
248
|
+
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
|
249
|
+
}
|
|
250
|
+
function normalizeCandidateSelectionError(error) {
|
|
251
|
+
return error instanceof Error ? error.message : String(error);
|
|
252
|
+
}
|
|
253
|
+
async function listLinkedInSessionCookieCandidates(supabase, options) {
|
|
254
|
+
const query = supabase
|
|
255
|
+
.from("linkedin_session_cookies")
|
|
256
|
+
.select([
|
|
257
|
+
"session_cookie_sha256",
|
|
258
|
+
"session_cookie_ciphertext",
|
|
259
|
+
"session_cookie_iv",
|
|
260
|
+
"session_cookie_auth_tag",
|
|
261
|
+
"last_user_email",
|
|
262
|
+
"last_user_handle",
|
|
263
|
+
"is_active",
|
|
264
|
+
"inactive_reason",
|
|
265
|
+
"last_validation_at",
|
|
266
|
+
"last_selected_at"
|
|
267
|
+
].join(","))
|
|
268
|
+
.eq("last_product_class", "sales_navigator")
|
|
269
|
+
.eq("last_sales_navigator_status", "ok")
|
|
270
|
+
.not("last_validation_at", "is", null)
|
|
271
|
+
.gte("last_validation_at", lookbackThresholdIso(ACTIVE_LINKEDIN_SESSION_LOOKBACK_HOURS))
|
|
272
|
+
.order("last_selected_at", { ascending: true, nullsFirst: true })
|
|
273
|
+
.order("last_validation_at", { ascending: false })
|
|
274
|
+
.limit(options.limit ?? MAX_RECOVERABLE_LINKEDIN_SESSION_CANDIDATES);
|
|
275
|
+
const filteredQuery = options.activeOnly
|
|
276
|
+
? query.eq("is_active", true)
|
|
277
|
+
: query
|
|
278
|
+
.eq("is_active", false)
|
|
279
|
+
.like("inactive_reason", `${RECOVERABLE_LINKEDIN_SESSION_INACTIVE_REASON_PREFIX}%`);
|
|
280
|
+
const result = await filteredQuery;
|
|
281
|
+
if (result.error) {
|
|
282
|
+
throw new Error(`Failed to query LinkedIn session cookie candidates: ${result.error.message}`);
|
|
283
|
+
}
|
|
284
|
+
const rows = (result.data ?? []);
|
|
285
|
+
return rows.filter((row) => !isExcludedLinkedInSessionIdentity({
|
|
286
|
+
userEmail: row.last_user_email ?? null,
|
|
287
|
+
userHandle: row.last_user_handle ?? null
|
|
288
|
+
}, options.exclusions));
|
|
289
|
+
}
|
|
290
|
+
async function buildClaimedLinkedInSessionCookieFromRow(supabase, row, source, exclusions, options) {
|
|
291
|
+
const claimedIdentity = {
|
|
292
|
+
userEmail: row.last_user_email ?? null,
|
|
293
|
+
userHandle: row.last_user_handle ?? null
|
|
294
|
+
};
|
|
295
|
+
if (isExcludedLinkedInSessionIdentity(claimedIdentity, exclusions)) {
|
|
296
|
+
await recordLinkedInSessionCookieAudit(supabase, {
|
|
297
|
+
sessionCookieSha256: row.session_cookie_sha256,
|
|
298
|
+
source: `${source}_excluded_identity`,
|
|
299
|
+
feedStatus: null,
|
|
300
|
+
salesNavigatorStatus: "ok",
|
|
301
|
+
recruiterStatus: null,
|
|
302
|
+
productClass: "sales_navigator",
|
|
303
|
+
isActive: true,
|
|
304
|
+
inactiveReason: null,
|
|
305
|
+
validationError: "excluded_linkedin_session_identity",
|
|
306
|
+
details: {
|
|
307
|
+
userEmail: claimedIdentity.userEmail,
|
|
308
|
+
userHandle: claimedIdentity.userHandle,
|
|
309
|
+
excludedUserEmails: exclusions.userEmails,
|
|
310
|
+
excludedUserHandles: exclusions.userHandles,
|
|
311
|
+
claimStrategy: options?.claimStrategy ?? null
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const sessionCookie = decryptLinkedInSessionCookie({
|
|
318
|
+
sessionCookieSha256: row.session_cookie_sha256,
|
|
319
|
+
sessionCookieCiphertext: row.session_cookie_ciphertext,
|
|
320
|
+
sessionCookieIv: row.session_cookie_iv,
|
|
321
|
+
sessionCookieAuthTag: row.session_cookie_auth_tag
|
|
322
|
+
}, options?.env ?? process.env);
|
|
323
|
+
if (!sessionCookie) {
|
|
324
|
+
throw new Error("Claimed LinkedIn session cookie could not be decrypted");
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
sessionCookieSha256: row.session_cookie_sha256,
|
|
328
|
+
sessionCookie,
|
|
329
|
+
userEmail: claimedIdentity.userEmail,
|
|
330
|
+
userHandle: claimedIdentity.userHandle,
|
|
331
|
+
claimStrategy: options?.claimStrategy,
|
|
332
|
+
previousInactiveReason: row.inactive_reason ?? null
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
await recordLinkedInSessionCookieAudit(supabase, {
|
|
337
|
+
sessionCookieSha256: row.session_cookie_sha256,
|
|
338
|
+
source: `${source}_decrypt_failure`,
|
|
339
|
+
feedStatus: null,
|
|
340
|
+
salesNavigatorStatus: "error",
|
|
341
|
+
recruiterStatus: null,
|
|
342
|
+
productClass: null,
|
|
343
|
+
isActive: false,
|
|
344
|
+
inactiveReason: "decrypt_failed",
|
|
345
|
+
validationError: error instanceof Error ? error.message : String(error),
|
|
346
|
+
details: {
|
|
347
|
+
userEmail: claimedIdentity.userEmail,
|
|
348
|
+
userHandle: claimedIdentity.userHandle,
|
|
349
|
+
claimStrategy: options?.claimStrategy ?? null
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async function claimLinkedInSessionCookieFromCandidates(supabase, source, exclusions, options) {
|
|
356
|
+
const candidates = await listLinkedInSessionCookieCandidates(supabase, {
|
|
357
|
+
activeOnly: options.activeOnly,
|
|
358
|
+
exclusions
|
|
359
|
+
});
|
|
360
|
+
for (const candidate of candidates) {
|
|
361
|
+
const claimed = await buildClaimedLinkedInSessionCookieFromRow(supabase, candidate, source, exclusions, {
|
|
362
|
+
claimStrategy: options.claimStrategy,
|
|
363
|
+
env: options.env
|
|
364
|
+
});
|
|
365
|
+
if (claimed) {
|
|
366
|
+
return claimed;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
export async function claimActiveLinkedInSessionCookie(supabase, source, env = process.env) {
|
|
372
|
+
if (typeof supabase.rpc !== "function") {
|
|
373
|
+
throw new Error("Supabase client does not support RPC");
|
|
374
|
+
}
|
|
375
|
+
const exclusions = resolveCliLinkedInSessionExclusions(env);
|
|
376
|
+
let lastClaimError = null;
|
|
377
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
378
|
+
try {
|
|
379
|
+
const result = await supabase
|
|
380
|
+
.rpc("claim_active_linkedin_session_cookie_excluding", {
|
|
381
|
+
selection_source: source,
|
|
382
|
+
excluded_user_emails: exclusions.userEmails,
|
|
383
|
+
excluded_user_handles: exclusions.userHandles
|
|
384
|
+
})
|
|
385
|
+
.single();
|
|
386
|
+
if (result.error) {
|
|
387
|
+
lastClaimError = result.error.message;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
const data = result.data;
|
|
391
|
+
if (!data) {
|
|
392
|
+
lastClaimError = "No active LinkedIn session cookies available";
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
const claimed = await buildClaimedLinkedInSessionCookieFromRow(supabase, data, source, exclusions, {
|
|
396
|
+
claimStrategy: "rpc_exclusion_rotation",
|
|
397
|
+
env
|
|
398
|
+
});
|
|
399
|
+
if (claimed) {
|
|
400
|
+
return claimed;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
lastClaimError = normalizeCandidateSelectionError(error);
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const directFallback = await claimLinkedInSessionCookieFromCandidates(supabase, source, exclusions, {
|
|
409
|
+
activeOnly: true,
|
|
410
|
+
claimStrategy: "direct_active_fallback",
|
|
411
|
+
env
|
|
412
|
+
});
|
|
413
|
+
if (directFallback) {
|
|
414
|
+
return directFallback;
|
|
415
|
+
}
|
|
416
|
+
if (lastClaimError &&
|
|
417
|
+
/claim_active_linkedin_session_cookie_excluding/i.test(lastClaimError)) {
|
|
418
|
+
throw new Error("LinkedIn session exclusion rotation RPC is unavailable. Apply the Salesprompter app migration that adds claim_active_linkedin_session_cookie_excluding before running Sales Navigator exports.");
|
|
419
|
+
}
|
|
420
|
+
if (lastClaimError) {
|
|
421
|
+
throw new Error(`Failed to claim LinkedIn session cookie: ${lastClaimError}`);
|
|
422
|
+
}
|
|
423
|
+
throw new Error("No non-excluded decryptable active LinkedIn session cookies available");
|
|
424
|
+
}
|
|
425
|
+
async function claimRecoverableInactiveLinkedInSessionCookie(supabase, source, exclusions, env = process.env, seenCookieHashes) {
|
|
426
|
+
const candidates = await listLinkedInSessionCookieCandidates(supabase, {
|
|
427
|
+
activeOnly: false,
|
|
428
|
+
exclusions
|
|
429
|
+
});
|
|
430
|
+
for (const candidate of candidates) {
|
|
431
|
+
if (seenCookieHashes?.has(candidate.session_cookie_sha256)) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const claimed = await buildClaimedLinkedInSessionCookieFromRow(supabase, candidate, source, exclusions, {
|
|
435
|
+
claimStrategy: "recoverable_inactive_fallback",
|
|
436
|
+
env
|
|
437
|
+
});
|
|
438
|
+
if (claimed) {
|
|
439
|
+
return claimed;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
async function describeLinkedInSessionPool(supabase, exclusions) {
|
|
445
|
+
const result = await supabase
|
|
446
|
+
.from("linkedin_session_cookies")
|
|
447
|
+
.select([
|
|
448
|
+
"last_user_email",
|
|
449
|
+
"last_user_handle",
|
|
450
|
+
"is_active",
|
|
451
|
+
"inactive_reason",
|
|
452
|
+
"last_validation_at",
|
|
453
|
+
"last_sales_navigator_status",
|
|
454
|
+
"last_product_class"
|
|
455
|
+
].join(","))
|
|
456
|
+
.eq("last_product_class", "sales_navigator");
|
|
457
|
+
if (result.error) {
|
|
458
|
+
throw new Error(`Failed to inspect LinkedIn session pool: ${result.error.message}`);
|
|
459
|
+
}
|
|
460
|
+
const rows = (result.data ?? []).filter((row) => !isExcludedLinkedInSessionIdentity({
|
|
461
|
+
userEmail: row.last_user_email ?? null,
|
|
462
|
+
userHandle: row.last_user_handle ?? null
|
|
463
|
+
}, exclusions));
|
|
464
|
+
const recentThresholdMs = Date.now() - ACTIVE_LINKEDIN_SESSION_LOOKBACK_HOURS * 60 * 60 * 1000;
|
|
465
|
+
const recentRows = rows.filter((row) => {
|
|
466
|
+
if (!row.last_validation_at) {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
const timestamp = Date.parse(row.last_validation_at);
|
|
470
|
+
return Number.isFinite(timestamp) && timestamp >= recentThresholdMs;
|
|
471
|
+
});
|
|
472
|
+
const eligibleActive = recentRows.filter((row) => row.is_active === true && row.last_sales_navigator_status === "ok").length;
|
|
473
|
+
const recoverableInactive = recentRows.filter((row) => row.is_active === false &&
|
|
474
|
+
row.last_sales_navigator_status === "ok" &&
|
|
475
|
+
Boolean(row.inactive_reason?.startsWith(RECOVERABLE_LINKEDIN_SESSION_INACTIVE_REASON_PREFIX))).length;
|
|
476
|
+
const recentStatusBreakdown = recentRows.reduce((accumulator, row) => {
|
|
477
|
+
const key = row.last_sales_navigator_status ?? "unknown";
|
|
478
|
+
accumulator[key] = (accumulator[key] ?? 0) + 1;
|
|
479
|
+
return accumulator;
|
|
480
|
+
}, {});
|
|
481
|
+
return {
|
|
482
|
+
totalNonExcludedSalesNavigatorCookies: rows.length,
|
|
483
|
+
recentNonExcludedSalesNavigatorCookies: recentRows.length,
|
|
484
|
+
eligibleActive,
|
|
485
|
+
recoverableInactive,
|
|
486
|
+
recentStatusBreakdown
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
async function buildLinkedInSessionPoolDiagnosticsMessage(supabase, exclusions) {
|
|
490
|
+
try {
|
|
491
|
+
const summary = await describeLinkedInSessionPool(supabase, exclusions);
|
|
492
|
+
const breakdown = Object.entries(summary.recentStatusBreakdown)
|
|
493
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
494
|
+
.map(([status, count]) => `${status}=${count}`)
|
|
495
|
+
.join(", ");
|
|
496
|
+
return ` LinkedIn session pool after exclusions: ${summary.totalNonExcludedSalesNavigatorCookies} sales-nav cookies, ${summary.recentNonExcludedSalesNavigatorCookies} recently validated, ${summary.eligibleActive} eligible active, ${summary.recoverableInactive} recoverable inactive${breakdown ? `; recent statuses: ${breakdown}` : ""}.`;
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
return ` LinkedIn session pool diagnostics unavailable: ${normalizeCandidateSelectionError(error)}.`;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
function extractTitle(body) {
|
|
503
|
+
const match = body.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
504
|
+
return match ? match[1].trim().replace(/\s+/g, " ") : null;
|
|
505
|
+
}
|
|
506
|
+
function normalizeLiAtCookie(sessionCookie) {
|
|
507
|
+
const trimmed = sessionCookie.trim();
|
|
508
|
+
return trimmed.startsWith("li_at=") ? trimmed.slice("li_at=".length) : trimmed;
|
|
509
|
+
}
|
|
510
|
+
function includesAny(value, patterns) {
|
|
511
|
+
const normalized = value ?? "";
|
|
512
|
+
return patterns.some((pattern) => pattern.test(normalized));
|
|
513
|
+
}
|
|
514
|
+
function isLoggedOut(result) {
|
|
515
|
+
return (includesAny(result.finalUrl, [/linkedin\.com\/login/i, /uas\/login/i, /checkpoint\/challenge/i]) ||
|
|
516
|
+
includesAny(result.title, [/login/i, /sign in/i]) ||
|
|
517
|
+
includesAny(result.body, [/session has expired/i, /sign in to linkedin/i, /checkpoint\/challenge/i]));
|
|
518
|
+
}
|
|
519
|
+
function isUpsell(result) {
|
|
520
|
+
return includesAny(result.body, [
|
|
521
|
+
/unlock sales navigator/i,
|
|
522
|
+
/find the right prospects/i,
|
|
523
|
+
/start your free trial/i,
|
|
524
|
+
/guest_login_sales_nav/i,
|
|
525
|
+
/premium\/products\/sales/i,
|
|
526
|
+
/you don'?t have a sales navigator account/i
|
|
527
|
+
]);
|
|
528
|
+
}
|
|
529
|
+
function hasSearchEntitlementSignals(result) {
|
|
530
|
+
const inSalesSurface = includesAny(result.finalUrl, [/\/sales\/search\/people/i, /\/sales\/home/i]);
|
|
531
|
+
if (!inSalesSurface) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
return (includesAny(result.title, [/sales navigator/i, /lead search/i]) ||
|
|
535
|
+
includesAny(result.body, [
|
|
536
|
+
/sales navigator/i,
|
|
537
|
+
/lead search/i,
|
|
538
|
+
/saved searches/i,
|
|
539
|
+
/personas/i,
|
|
540
|
+
/viewallfilters=true/i,
|
|
541
|
+
/current job title/i,
|
|
542
|
+
/company headcount/i,
|
|
543
|
+
/search keywords/i,
|
|
544
|
+
/sales\/search\/people/i,
|
|
545
|
+
/accounts<\/span>/i,
|
|
546
|
+
/leads<\/span>/i,
|
|
547
|
+
/messaging<\/span>/i
|
|
548
|
+
]));
|
|
549
|
+
}
|
|
550
|
+
export async function fetchHtmlWithSessionCookie(url, sessionCookie, options) {
|
|
551
|
+
const normalizedSessionCookie = normalizeLiAtCookie(sessionCookie);
|
|
552
|
+
const redirectChain = [];
|
|
553
|
+
let currentUrl = url;
|
|
554
|
+
const seenUrls = new Set([url]);
|
|
555
|
+
for (let index = 0; index < 5; index += 1) {
|
|
556
|
+
const controller = new AbortController();
|
|
557
|
+
const timeout = setTimeout(() => controller.abort(), options?.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
558
|
+
try {
|
|
559
|
+
const response = await fetch(currentUrl, {
|
|
560
|
+
method: "GET",
|
|
561
|
+
redirect: "manual",
|
|
562
|
+
signal: controller.signal,
|
|
563
|
+
headers: {
|
|
564
|
+
Cookie: `li_at=${normalizedSessionCookie}`,
|
|
565
|
+
"User-Agent": options?.userAgent ?? DEFAULT_USER_AGENT,
|
|
566
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
567
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
568
|
+
"Cache-Control": "no-cache",
|
|
569
|
+
Pragma: "no-cache"
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
clearTimeout(timeout);
|
|
573
|
+
const location = response.headers.get("location");
|
|
574
|
+
if (location && [301, 302, 303, 307, 308].includes(response.status)) {
|
|
575
|
+
const nextUrl = new URL(location, currentUrl).toString();
|
|
576
|
+
redirectChain.push({
|
|
577
|
+
status: response.status,
|
|
578
|
+
location: nextUrl
|
|
579
|
+
});
|
|
580
|
+
if (seenUrls.has(nextUrl)) {
|
|
581
|
+
return {
|
|
582
|
+
status: response.status,
|
|
583
|
+
finalUrl: nextUrl,
|
|
584
|
+
redirectChain,
|
|
585
|
+
title: null,
|
|
586
|
+
body: "",
|
|
587
|
+
error: "redirect_loop"
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
seenUrls.add(nextUrl);
|
|
591
|
+
currentUrl = nextUrl;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const body = await response.text();
|
|
595
|
+
return {
|
|
596
|
+
status: response.status,
|
|
597
|
+
finalUrl: currentUrl,
|
|
598
|
+
redirectChain,
|
|
599
|
+
title: extractTitle(body),
|
|
600
|
+
body,
|
|
601
|
+
error: null
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
clearTimeout(timeout);
|
|
606
|
+
return {
|
|
607
|
+
status: null,
|
|
608
|
+
finalUrl: currentUrl,
|
|
609
|
+
redirectChain,
|
|
610
|
+
title: null,
|
|
611
|
+
body: "",
|
|
612
|
+
error: error instanceof Error
|
|
613
|
+
? error.name === "AbortError"
|
|
614
|
+
? "timeout"
|
|
615
|
+
: error.message
|
|
616
|
+
: String(error)
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
status: null,
|
|
622
|
+
finalUrl: currentUrl,
|
|
623
|
+
redirectChain,
|
|
624
|
+
title: null,
|
|
625
|
+
body: "",
|
|
626
|
+
error: "too_many_redirects"
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
export async function probeSalesNavigatorSearchSession(sessionCookie, queryUrl, options) {
|
|
630
|
+
const result = await fetchHtmlWithSessionCookie(queryUrl, sessionCookie, {
|
|
631
|
+
timeoutMs: options?.timeoutMs
|
|
632
|
+
});
|
|
633
|
+
let status = "unknown";
|
|
634
|
+
if (result.error) {
|
|
635
|
+
status = "error";
|
|
636
|
+
}
|
|
637
|
+
else if (result.status === 999 || result.status === 429) {
|
|
638
|
+
status = "rate_limited";
|
|
639
|
+
}
|
|
640
|
+
else if (isLoggedOut(result)) {
|
|
641
|
+
status = "logged_out";
|
|
642
|
+
}
|
|
643
|
+
else if (isUpsell(result)) {
|
|
644
|
+
status = "upsell";
|
|
645
|
+
}
|
|
646
|
+
else if (hasSearchEntitlementSignals(result)) {
|
|
647
|
+
status = "ok";
|
|
648
|
+
}
|
|
649
|
+
else if (includesAny(result.finalUrl, [/\/sales\//i])) {
|
|
650
|
+
status = "unknown";
|
|
651
|
+
}
|
|
652
|
+
const validationError = status === "ok"
|
|
653
|
+
? null
|
|
654
|
+
: result.error ??
|
|
655
|
+
(status === "logged_out"
|
|
656
|
+
? "linkedin_session_invalid"
|
|
657
|
+
: status === "upsell"
|
|
658
|
+
? "sales_navigator_upsell_detected"
|
|
659
|
+
: status === "rate_limited"
|
|
660
|
+
? "linkedin_rate_limited"
|
|
661
|
+
: "sales_navigator_search_probe_failed");
|
|
662
|
+
return {
|
|
663
|
+
status,
|
|
664
|
+
finalUrl: result.finalUrl,
|
|
665
|
+
title: result.title,
|
|
666
|
+
redirectChain: result.redirectChain,
|
|
667
|
+
validationError,
|
|
668
|
+
details: {
|
|
669
|
+
statusCode: result.status,
|
|
670
|
+
error: result.error,
|
|
671
|
+
redirectCount: result.redirectChain.length,
|
|
672
|
+
queryUrl
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
export async function claimValidatedSalesNavigatorSessionCookieForCli(options) {
|
|
677
|
+
const env = options.env ?? process.env;
|
|
678
|
+
const supabase = options.supabase ?? createLinkedInSessionSupabaseClient(env);
|
|
679
|
+
if (!supabase) {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
const seenCookieHashes = new Set();
|
|
683
|
+
const startedAt = Date.now();
|
|
684
|
+
let lastFailureMessage = null;
|
|
685
|
+
let attempts = 0;
|
|
686
|
+
const exclusions = resolveCliLinkedInSessionExclusions(env);
|
|
687
|
+
for (let attempt = 0; attempt < MAX_COOKIE_VALIDATION_ATTEMPTS; attempt += 1) {
|
|
688
|
+
if (Date.now() - startedAt >= COOKIE_VALIDATION_TIMEOUT_MS) {
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
let claimedSession = null;
|
|
692
|
+
let claimErrorMessage = null;
|
|
693
|
+
try {
|
|
694
|
+
claimedSession = await claimActiveLinkedInSessionCookie(supabase, options.source, env);
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
claimErrorMessage = normalizeCandidateSelectionError(error);
|
|
698
|
+
}
|
|
699
|
+
if (!claimedSession) {
|
|
700
|
+
claimedSession = await claimRecoverableInactiveLinkedInSessionCookie(supabase, options.source, exclusions, env, seenCookieHashes);
|
|
701
|
+
}
|
|
702
|
+
if (!claimedSession) {
|
|
703
|
+
lastFailureMessage =
|
|
704
|
+
claimErrorMessage ?? "No non-excluded decryptable active LinkedIn session cookies available";
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
attempts += 1;
|
|
708
|
+
if (seenCookieHashes.has(claimedSession.sessionCookieSha256)) {
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
seenCookieHashes.add(claimedSession.sessionCookieSha256);
|
|
712
|
+
const probe = await probeSalesNavigatorSearchSession(claimedSession.sessionCookie, options.queryUrl, {
|
|
713
|
+
timeoutMs: COOKIE_PROBE_TIMEOUT_MS
|
|
714
|
+
});
|
|
715
|
+
const shouldDeactivate = probe.status === "logged_out" || probe.status === "upsell";
|
|
716
|
+
await recordLinkedInSessionCookieAudit(supabase, {
|
|
717
|
+
sessionCookieSha256: claimedSession.sessionCookieSha256,
|
|
718
|
+
source: `${options.source}_preflight`,
|
|
719
|
+
feedStatus: null,
|
|
720
|
+
salesNavigatorStatus: probe.status,
|
|
721
|
+
recruiterStatus: null,
|
|
722
|
+
productClass: probe.status === "ok" ? "sales_navigator" : null,
|
|
723
|
+
isActive: !shouldDeactivate,
|
|
724
|
+
inactiveReason: shouldDeactivate
|
|
725
|
+
? probe.status === "upsell"
|
|
726
|
+
? "salesnav_search_upsell_detected"
|
|
727
|
+
: "linkedin_session_invalid"
|
|
728
|
+
: null,
|
|
729
|
+
validationError: probe.validationError,
|
|
730
|
+
details: {
|
|
731
|
+
attemptNumber: attempts,
|
|
732
|
+
validationElapsedMs: Date.now() - startedAt,
|
|
733
|
+
claimStrategy: claimedSession.claimStrategy ?? null,
|
|
734
|
+
previousInactiveReason: claimedSession.previousInactiveReason ?? null,
|
|
735
|
+
...probe.details,
|
|
736
|
+
finalUrl: probe.finalUrl,
|
|
737
|
+
title: probe.title,
|
|
738
|
+
redirectChain: probe.redirectChain.slice(0, 5)
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
if (probe.status === "ok") {
|
|
742
|
+
return claimedSession;
|
|
743
|
+
}
|
|
744
|
+
lastFailureMessage =
|
|
745
|
+
probe.validationError ?? `Sales Navigator search probe returned ${probe.status}`;
|
|
746
|
+
}
|
|
747
|
+
const poolDiagnostics = await buildLinkedInSessionPoolDiagnosticsMessage(supabase, exclusions);
|
|
748
|
+
throw new Error(lastFailureMessage
|
|
749
|
+
? `No validated LinkedIn Sales Navigator session cookies available after ${attempts} preflight attempt${attempts === 1 ? "" : "s"}: ${lastFailureMessage}${poolDiagnostics}`
|
|
750
|
+
: `No validated LinkedIn Sales Navigator session cookies available after ${attempts} preflight attempt${attempts === 1 ? "" : "s"}${poolDiagnostics}`);
|
|
751
|
+
}
|