salesprompter-cli 0.1.40 → 0.1.42

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/dist/cli.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { createHash } from "node:crypto";
4
3
  import { access, appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
5
4
  import { createRequire } from "node:module";
6
5
  import os from "node:os";
@@ -9,9 +8,10 @@ import { emitKeypressEvents } from "node:readline";
9
8
  import { createInterface } from "node:readline/promises";
10
9
  import { setTimeout as delay } from "node:timers/promises";
11
10
  import { createClient } from "@supabase/supabase-js";
11
+ import pg from "pg";
12
12
  import { Command } from "commander";
13
13
  import { z } from "zod";
14
- import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, requireAuthSession, shouldBypassAuth, verifySession, writeAuthSession } from "./auth.js";
14
+ import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, readAuthSession, requireAuthSession, shouldBypassAuth, verifySession, writeAuthSession } from "./auth.js";
15
15
  import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
16
16
  import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
17
17
  import { auditDomainDecisions, buildDomainfinderBacklogQueries, buildDomainfinderCandidatesSql, buildDomainfinderInputSql, buildDomainfinderWritebackSql, buildExistingDomainRepairSql, buildExistingDomainAuditQueries, compareDomainSelectionStrategies, selectBestDomains } from "./domainfinder.js";
@@ -26,11 +26,10 @@ import { InstantlySyncProvider } from "./instantly.js";
26
26
  import { backfillLinkedInCompanies } from "./linkedin-companies.js";
27
27
  import { parseLinkedInCompanyPage } from "./linkedin-companies.js";
28
28
  import { crawlLinkedInProductCategory } from "./linkedin-products.js";
29
- import { claimValidatedSalesNavigatorSessionCookieForCli, createLinkedInSessionSupabaseClient, probeSalesNavigatorSearchSession, resolveConfiguredEnvValue } from "./linkedin-session.js";
30
- import { extractLiAtCookieValue } from "./linkedin-session-contracts.js";
29
+ import { claimValidatedSalesNavigatorSessionCookieForCli, createLinkedInSessionSupabaseClient, resolveConfiguredEnvValue } from "./linkedin-session.js";
31
30
  import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
32
31
  import { readJsonFile, splitCsv, writeJsonFile, writeTextFile } from "./io.js";
33
- import { buildSalesNavigatorCrawlPreview, createSalesNavigatorCrawlSeed, DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS, buildSalesNavigatorPeopleSlice, deriveSalesNavigatorTitleQuerySeeds, expandSalesNavigatorCrawlAttempt, SalesNavigatorSliceTooBroadError } from "./sales-navigator.js";
32
+ import { buildSalesNavigatorCrawlPreview, createSalesNavigatorCrawlSeed, DEFAULT_SALES_NAVIGATOR_ACCOUNT_CRAWL_DIMENSIONS, DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS, buildSalesNavigatorPeopleSearchUrl, buildSalesNavigatorPeopleSlice, deriveSalesNavigatorTitleQuerySeeds, executeSalesNavigatorAdaptiveCrawl, expandSalesNavigatorCrawlAttempt, SalesNavigatorCrawlStopError, SalesNavigatorSliceTooBroadError } from "./sales-navigator.js";
34
33
  import { buildSalesNavigatorHistoricalBackfillPlan, ensureSalesNavigatorPeopleCount, resolveSalesNavigatorHistoricalBackfillConfig, resolveSalesNavigatorHistoricalBackfillResumeState, resolveSalesNavigatorHistoricalBackfillOrgId, salesNavigatorHistoricalBackfillDefaults } from "./salesnav-backfill.js";
35
34
  const require = createRequire(import.meta.url);
36
35
  const { version: packageVersion } = require("../package.json");
@@ -129,69 +128,17 @@ const SalesNavigatorLocalImportResponseSchema = z.object({
129
128
  runId: z.string().min(1),
130
129
  containerId: z.string().min(1),
131
130
  imported: z.number().int().nonnegative(),
132
- upserted: z.number().int().nonnegative()
133
- });
134
- const LeadPoolAutoProcessResponseSchema = z.object({
135
- success: z.boolean(),
136
- clientIds: z.array(z.number().int().positive()),
137
- steps: z.array(z.record(z.string(), z.unknown())),
138
- startedAt: z.string(),
139
- finishedAt: z.string()
140
- });
141
- const AffiliateCampaignLaunchResponseSchema = z
142
- .object({
143
- status: z.string().min(1),
144
- dryRun: z.boolean(),
145
- product: z.object({
146
- domain: z.string().min(1),
147
- affiliateLink: z.string().url()
148
- }),
149
- audience: z.object({
150
- sourceType: z.string().min(1),
151
- linkedInUrl: z.string().url(),
152
- maxResults: z.number().int().positive()
153
- }),
154
- extraction: z
131
+ upserted: z.number().int().nonnegative(),
132
+ leadPoolRefresh: z
155
133
  .object({
156
- provider: z.string().min(1),
157
- agentId: z.string().min(1).optional(),
158
- containerId: z.string().min(1).optional(),
159
- eventTypes: z.array(z.string()).optional(),
160
- monitorId: z.string().min(1).optional(),
161
- postsDiscoveredCount: z.number().int().nonnegative().optional(),
162
- engagementEventsCount: z.number().int().nonnegative().optional(),
163
- peopleDiscoveredCount: z.number().int().nonnegative().optional(),
164
- people: z.array(z.unknown()).optional(),
165
- posts: z.array(z.unknown()).optional(),
166
- run: z.unknown().nullable().optional(),
167
- summary: z.unknown().nullable().optional(),
168
- selectedSessionCookieSha256: z.string().nullable().optional(),
169
- selectedSessionUserEmail: z.string().nullable().optional(),
170
- selectedSessionUserHandle: z.string().nullable().optional()
134
+ scheduled: z.literal(true),
171
135
  })
136
+ .nullable()
172
137
  .optional(),
173
- previewUrl: z.string().min(1).nullable().optional(),
174
- next: z.string().optional()
175
- })
176
- .passthrough();
177
- const LeadPoolEmailFinderProcessResponseSchema = z.object({
178
- success: z.boolean(),
179
- provider: z.string().optional(),
180
- selected: z.number().int().nonnegative(),
181
- found: z.number().int().nonnegative(),
182
- notFound: z.number().int().nonnegative(),
183
- failed: z.number().int().nonnegative(),
184
- durationMs: z.number().nonnegative(),
185
- launched: z.boolean().optional(),
186
- agentId: z.string().nullable().optional(),
187
- containerId: z.string().nullable().optional(),
188
- csvInputUrl: z.string().nullable().optional(),
189
- processedAt: z.string()
190
138
  });
191
139
  const PhantombusterContainersSyncResponseSchema = z.object({
192
140
  status: z.literal("ok"),
193
141
  agentIds: z.array(z.string().min(1)),
194
- containerIds: z.array(z.string().min(1)).optional(),
195
142
  agents: z.array(z.object({
196
143
  agentId: z.string().min(1),
197
144
  fetched: z.number().int().nonnegative(),
@@ -203,13 +150,7 @@ const PhantombusterContainersSyncResponseSchema = z.object({
203
150
  resultsSynced: z.number().int().nonnegative(),
204
151
  outputsStored: z.number().int().nonnegative(),
205
152
  resultObjectsStored: z.number().int().nonnegative(),
206
- resultRowsStored: z.number().int().nonnegative(),
207
- leadListsProjected: z.number().int().nonnegative().optional(),
208
- leadListContactsProjected: z.number().int().nonnegative().optional(),
209
- contactsProjected: z.number().int().nonnegative().optional(),
210
- leadPoolRows: z.number().int().nonnegative().nullable().optional(),
211
- qualifiedContacts: z.number().int().nonnegative().nullable().optional(),
212
- qualifiedCompanies: z.number().int().nonnegative().nullable().optional()
153
+ resultRowsStored: z.number().int().nonnegative()
213
154
  });
214
155
  const CliEmailEnrichmentCompaniesResponseSchema = z.object({
215
156
  clientId: z.number().int().positive(),
@@ -379,6 +320,47 @@ const SalesNavigatorCrawlReportResponseSchema = z.object({
379
320
  status: z.literal("ok"),
380
321
  job: SalesNavigatorCrawlJobSummarySchema
381
322
  });
323
+ const SalesNavigatorAccountRunStatusSchema = z.object({
324
+ runId: z.string().uuid(),
325
+ orgId: z.string().min(1),
326
+ sourceQueryUrl: z.string().url(),
327
+ status: z.string().min(1),
328
+ storedAccounts: z.number().int().nonnegative(),
329
+ reportedRootTotal: z.number().int().nonnegative().nullable(),
330
+ missingStoredFromRoot: z.number().int().nonnegative().nullable(),
331
+ lastError: z.string().nullable(),
332
+ updatedAt: z.string().datetime(),
333
+ sliceCounts: z.record(z.string(), z.number().int().nonnegative()),
334
+ workflow: z.object({
335
+ latestRunId: z.string().min(1).nullable(),
336
+ latestStartedAt: z.string().datetime().nullable(),
337
+ }).default({
338
+ latestRunId: null,
339
+ latestStartedAt: null,
340
+ }),
341
+ nextAction: z.enum(["complete", "cooldown", "resume_cli", "inspect"]),
342
+ cooldownUntil: z.string().datetime().nullable(),
343
+ problemSlices: z.array(z.object({
344
+ sliceId: z.string().uuid(),
345
+ status: z.string().min(1),
346
+ totalResults: z.number().int().nonnegative().nullable(),
347
+ storedAccounts: z.number().int().nonnegative(),
348
+ lastError: z.string().nullable(),
349
+ slicedQueryUrl: z.string().url(),
350
+ stopReason: z.string().nullable(),
351
+ nextStart: z.number().int().nonnegative().nullable(),
352
+ missingFromReported: z.number().int().nonnegative().nullable()
353
+ }))
354
+ });
355
+ const SalesNavigatorAccountRunStatusResponseSchema = z.object({
356
+ status: z.literal("ok"),
357
+ run: SalesNavigatorAccountRunStatusSchema
358
+ });
359
+ const SalesNavigatorAccountWorkflowStartResponseSchema = z.object({
360
+ status: z.literal("accepted"),
361
+ workflowRunId: z.string().min(1),
362
+ accountRunId: z.string().uuid()
363
+ });
382
364
  const cliPacks = [
383
365
  {
384
366
  slug: "contacts",
@@ -391,21 +373,32 @@ const cliPacks = [
391
373
  slug: "research",
392
374
  title: "Research",
393
375
  summary: "Scrape markets and enrich companies before outreach.",
394
- commands: ["market:scrape", "companies:enrich", "leads:process"],
376
+ commands: ["market:scrape", "companies:enrich"],
395
377
  installStatus: "included"
396
378
  },
397
379
  {
398
380
  slug: "discovery",
399
381
  title: "Discovery",
400
382
  summary: "Find leads from product and market inputs.",
401
- commands: ["leads:discover", "search:run", "search:status", "search:export", "search:local-import", "search:count"],
383
+ commands: [
384
+ "leads:discover",
385
+ "search:run",
386
+ "search:status",
387
+ "search:export",
388
+ "search:count",
389
+ "accounts:split",
390
+ "accounts:local-import",
391
+ "accounts:status",
392
+ "accounts:resume",
393
+ "accounts:repair"
394
+ ],
402
395
  installStatus: "included"
403
396
  },
404
397
  {
405
398
  slug: "outreach",
406
399
  title: "Outreach",
407
400
  summary: "Prepare and sync qualified leads into downstream systems.",
408
- commands: ["affiliate:launch", "sync:outreach", "sync:crm"],
401
+ commands: ["sync:outreach", "sync:crm"],
409
402
  installStatus: "included"
410
403
  }
411
404
  ];
@@ -419,8 +412,12 @@ const helpAliasByCommandName = new Map([
419
412
  ["salesnav:crawl", "search:run"],
420
413
  ["salesnav:crawl:status", "search:status"],
421
414
  ["salesnav:export", "search:export"],
422
- ["salesnav:local-import", "search:local-import"],
423
- ["salesnav:count", "search:count"]
415
+ ["salesnav:count", "search:count"],
416
+ ["salesnav:accounts:split", "accounts:split"],
417
+ ["salesnav:accounts:local-import", "accounts:local-import"],
418
+ ["salesnav:accounts:status", "accounts:status"],
419
+ ["salesnav:accounts:resume", "accounts:resume"],
420
+ ["salesnav:accounts:repair", "accounts:repair"]
424
421
  ]);
425
422
  const helpVisibleCommandNames = new Set([
426
423
  "setup",
@@ -443,9 +440,7 @@ const helpVisibleCommandNames = new Set([
443
440
  "leads:generate",
444
441
  "leads:enrich",
445
442
  "leads:score",
446
- "leads:process",
447
443
  "leads:pipeline",
448
- "affiliate:launch",
449
444
  "sync:crm",
450
445
  "sync:outreach",
451
446
  "linkedin-companies:backfill",
@@ -454,8 +449,12 @@ const helpVisibleCommandNames = new Set([
454
449
  "salesnav:crawl",
455
450
  "salesnav:crawl:status",
456
451
  "salesnav:export",
457
- "salesnav:local-import",
458
- "salesnav:count"
452
+ "salesnav:count",
453
+ "salesnav:accounts:split",
454
+ "salesnav:accounts:local-import",
455
+ "salesnav:accounts:status",
456
+ "salesnav:accounts:resume",
457
+ "salesnav:accounts:repair"
459
458
  ]);
460
459
  function printOutput(value) {
461
460
  if (runtimeOutputOptions.quiet) {
@@ -1382,22 +1381,12 @@ async function listChromeExtensionTokenLogCandidates() {
1382
1381
  if (!file.endsWith(".log")) {
1383
1382
  continue;
1384
1383
  }
1385
- const filePath = path.join(extensionDir, file);
1386
- let mtimeMs = 0;
1387
- try {
1388
- mtimeMs = (await stat(filePath)).mtimeMs;
1389
- }
1390
- catch {
1391
- mtimeMs = 0;
1392
- }
1393
- paths.push({ path: filePath, mtimeMs });
1384
+ paths.push(path.join(extensionDir, file));
1394
1385
  }
1395
1386
  }
1396
1387
  }
1397
1388
  }
1398
- return paths
1399
- .sort((left, right) => right.mtimeMs - left.mtimeMs || right.path.localeCompare(left.path))
1400
- .map((entry) => entry.path);
1389
+ return paths.sort().reverse();
1401
1390
  }
1402
1391
  async function readLocalLinkedInExtensionDirectLookupConfig() {
1403
1392
  const candidates = await listChromeExtensionTokenLogCandidates();
@@ -1436,6 +1425,16 @@ function readLinkedInDirectLookupEnvConfig() {
1436
1425
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
1437
1426
  };
1438
1427
  }
1428
+ function shouldDisableLinkedInDirectLookupAutodiscovery() {
1429
+ const value = process.env.SALESPROMPTER_DISABLE_LINKEDIN_DIRECT_LOOKUP_AUTODISCOVERY ??
1430
+ process.env.LINKEDIN_DISABLE_DIRECT_LOOKUP_AUTODISCOVERY ??
1431
+ "";
1432
+ return ["1", "true", "on", "yes"].includes(value.trim().toLowerCase());
1433
+ }
1434
+ function isExplicitLinkedInSessionVaultEnabled() {
1435
+ const value = process.env.SALESPROMPTER_CLI_MANAGE_LINKEDIN_SESSIONS ?? "";
1436
+ return ["1", "true", "on", "yes"].includes(value.trim().toLowerCase());
1437
+ }
1439
1438
  function extractStoredLinkedInLookupValue(metadata, key) {
1440
1439
  if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
1441
1440
  return "";
@@ -1495,18 +1494,24 @@ async function readLinkedInDirectLookupConfig() {
1495
1494
  cachedLinkedInDirectLookupConfig = envConfig;
1496
1495
  return envConfig;
1497
1496
  }
1498
- if (process.env.SALESPROMPTER_DISABLE_LINKEDIN_DIRECT_LOOKUP_AUTODISCOVERY === "1") {
1499
- throw new Error("Missing LinkedIn direct lookup session. Set LINKEDIN_CSRF_TOKEN, LINKEDIN_X_LI_IDENTITY, and LINKEDIN_SALES_NAV_COOKIE, or connect the Salesprompter Chrome extension to sync your LinkedIn session to salesprompter.ai.");
1497
+ try {
1498
+ const storedConfig = await readStoredLinkedInDirectLookupConfig();
1499
+ if (storedConfig) {
1500
+ cachedLinkedInDirectLookupConfig = storedConfig;
1501
+ return storedConfig;
1502
+ }
1500
1503
  }
1501
- const localExtensionConfig = await readLocalLinkedInExtensionDirectLookupConfig();
1502
- if (localExtensionConfig) {
1503
- cachedLinkedInDirectLookupConfig = localExtensionConfig;
1504
- return localExtensionConfig;
1504
+ catch (error) {
1505
+ if (isExplicitLinkedInSessionVaultEnabled()) {
1506
+ throw error;
1507
+ }
1505
1508
  }
1506
- const storedConfig = await readStoredLinkedInDirectLookupConfig();
1507
- if (storedConfig) {
1508
- cachedLinkedInDirectLookupConfig = storedConfig;
1509
- return storedConfig;
1509
+ if (!shouldDisableLinkedInDirectLookupAutodiscovery()) {
1510
+ const localExtensionConfig = await readLocalLinkedInExtensionDirectLookupConfig();
1511
+ if (localExtensionConfig) {
1512
+ cachedLinkedInDirectLookupConfig = localExtensionConfig;
1513
+ return localExtensionConfig;
1514
+ }
1510
1515
  }
1511
1516
  throw new Error("Missing LinkedIn direct lookup session. Set LINKEDIN_CSRF_TOKEN, LINKEDIN_X_LI_IDENTITY, and LINKEDIN_SALES_NAV_COOKIE, or connect the Salesprompter Chrome extension to sync your LinkedIn session to salesprompter.ai.");
1512
1517
  }
@@ -1997,25 +2002,198 @@ function scoreLinkedInSalesApiElementMatch(contact, element) {
1997
2002
  companyMatchCount
1998
2003
  };
1999
2004
  }
2000
- function extractLinkedInCompanyEmployeeCountFromSalesApiElement(element) {
2005
+ function extractLinkedInCompanyEmployeeCountFromSalesApiElement(element, options = {}) {
2006
+ if (!element) {
2007
+ return null;
2008
+ }
2009
+ for (const key of ["employeeDisplayCount", "employeesOnLinkedInCount"]) {
2010
+ const value = element[key];
2011
+ if (typeof value === "number" && Number.isFinite(value)) {
2012
+ return Math.max(0, Math.trunc(value));
2013
+ }
2014
+ if (typeof value === "string") {
2015
+ const parsed = parseLinkedInCompactNumber(value);
2016
+ if (parsed != null) {
2017
+ return parsed;
2018
+ }
2019
+ }
2020
+ }
2021
+ if (options.allowTextFallback) {
2022
+ for (const value of collectNestedStrings(element)) {
2023
+ const match = value.match(/(\d[\d.,]*)\s+employees\b/i);
2024
+ if (match) {
2025
+ return Number(match[1].replace(/[.,]/g, ""));
2026
+ }
2027
+ }
2028
+ }
2029
+ return null;
2030
+ }
2031
+ function extractLinkedInCompanyNumericTextFromSalesApiElement(element, directKeys, pattern) {
2001
2032
  if (!element) {
2002
2033
  return null;
2003
2034
  }
2004
- const numericCandidates = [
2005
- typeof element.employeeCount === "number" ? element.employeeCount : null,
2006
- typeof element.employeesOnLinkedInCount === "number" ? element.employeesOnLinkedInCount : null
2007
- ].filter((value) => Number.isFinite(value));
2008
- if (numericCandidates.length > 0) {
2009
- return Math.max(0, Math.trunc(numericCandidates[0] ?? 0));
2035
+ for (const key of directKeys) {
2036
+ const value = element[key];
2037
+ if (typeof value === "number" && Number.isFinite(value)) {
2038
+ return Math.max(0, Math.trunc(value));
2039
+ }
2040
+ if (typeof value === "string") {
2041
+ const parsed = parseLinkedInCompactNumber(value);
2042
+ if (parsed != null) {
2043
+ return parsed;
2044
+ }
2045
+ }
2010
2046
  }
2011
2047
  for (const value of collectNestedStrings(element)) {
2012
- const match = value.match(/(\d[\d.,]*)\s+employees\b/i);
2013
- if (match) {
2014
- return Number(match[1].replace(/[.,]/g, ""));
2048
+ const match = value.match(pattern);
2049
+ if (match?.[1]) {
2050
+ const parsed = parseLinkedInCompactNumber(match[1]);
2051
+ if (parsed != null) {
2052
+ return parsed;
2053
+ }
2015
2054
  }
2016
2055
  }
2017
2056
  return null;
2018
2057
  }
2058
+ function parseLinkedInCompactNumber(value) {
2059
+ const normalized = value.trim().replace(/,/g, "");
2060
+ const match = normalized.match(/^(\d+(?:\.\d+)?)\s*([kmb])?\+?$/i);
2061
+ if (!match) {
2062
+ const digits = normalized.match(/\d[\d.]*/)?.[0];
2063
+ return digits ? Number(digits.replace(/\./g, "")) : null;
2064
+ }
2065
+ const amount = Number(match[1]);
2066
+ if (!Number.isFinite(amount)) {
2067
+ return null;
2068
+ }
2069
+ const suffix = match[2]?.toLowerCase();
2070
+ const multiplier = suffix === "b" ? 1_000_000_000 : suffix === "m" ? 1_000_000 : suffix === "k" ? 1_000 : 1;
2071
+ return Math.max(0, Math.trunc(amount * multiplier));
2072
+ }
2073
+ function extractFirstNestedStringByKeys(element, keys) {
2074
+ if (!element) {
2075
+ return null;
2076
+ }
2077
+ for (const key of keys) {
2078
+ const direct = element[key];
2079
+ if (typeof direct === "string" && normalizeLookupWhitespace(direct)) {
2080
+ return normalizeLookupWhitespace(direct);
2081
+ }
2082
+ }
2083
+ const normalizedKeys = new Set(keys.map((key) => key.toLowerCase()));
2084
+ const visit = (value, seen = new Set()) => {
2085
+ if (value == null || seen.has(value) || typeof value !== "object") {
2086
+ return null;
2087
+ }
2088
+ seen.add(value);
2089
+ if (Array.isArray(value)) {
2090
+ for (const item of value) {
2091
+ const found = visit(item, seen);
2092
+ if (found) {
2093
+ return found;
2094
+ }
2095
+ }
2096
+ return null;
2097
+ }
2098
+ const record = value;
2099
+ for (const [key, candidate] of Object.entries(record)) {
2100
+ if (normalizedKeys.has(key.toLowerCase()) &&
2101
+ typeof candidate === "string" &&
2102
+ normalizeLookupWhitespace(candidate)) {
2103
+ return normalizeLookupWhitespace(candidate);
2104
+ }
2105
+ }
2106
+ for (const candidate of Object.values(record)) {
2107
+ const found = visit(candidate, seen);
2108
+ if (found) {
2109
+ return found;
2110
+ }
2111
+ }
2112
+ return null;
2113
+ };
2114
+ return visit(element);
2115
+ }
2116
+ function extractLinkedInCompanyIdFromUrl(value) {
2117
+ if (!value) {
2118
+ return null;
2119
+ }
2120
+ const match = value.match(/\/(?:sales\/company|company)\/([^/?#]+)/i);
2121
+ return match?.[1] ? decodeURIComponent(match[1]) : null;
2122
+ }
2123
+ function extractLinkedInSalesCompanyIdFromUrn(value) {
2124
+ if (typeof value !== "string") {
2125
+ return null;
2126
+ }
2127
+ const directMatch = value.match(/urn:li:fs_salesCompany:(\d+)\b/i);
2128
+ if (directMatch?.[1]) {
2129
+ return directMatch[1];
2130
+ }
2131
+ const tupleMatch = value.match(/urn:li:fs_salesCompany:\((\d+),/i);
2132
+ return tupleMatch?.[1] ?? null;
2133
+ }
2134
+ function normalizeLocalSalesNavigatorAccount(element, queryUrl) {
2135
+ const urnCompanyId = extractLinkedInSalesCompanyIdFromUrn(element.entityUrn);
2136
+ const explicitSalesNavCompanyUrl = extractLinkedInSalesNavCompanyUrlFromSalesApiElement(element);
2137
+ const explicitLinkedinCompanyUrl = extractLinkedInCompanyUrlFromSalesApiElement(element);
2138
+ const companyName = extractLinkedInCompanyNameFromSalesApiElement(element);
2139
+ const companyId = extractLinkedInCompanyIdFromUrl(explicitSalesNavCompanyUrl) ??
2140
+ extractLinkedInCompanyIdFromUrl(explicitLinkedinCompanyUrl) ??
2141
+ urnCompanyId;
2142
+ const salesNavCompanyUrl = explicitSalesNavCompanyUrl ??
2143
+ (companyId ? `https://www.linkedin.com/sales/company/${companyId}` : null);
2144
+ const linkedinCompanyUrl = explicitLinkedinCompanyUrl ??
2145
+ (companyId ? normalizeLinkedInCompanyPage(companyId) : null);
2146
+ const accountKey = (companyId ? `company-id:${companyId}` : null) ??
2147
+ salesNavCompanyUrl?.toLowerCase() ??
2148
+ linkedinCompanyUrl?.toLowerCase() ??
2149
+ (companyName ? `company-name:${normalizeLooseMatchText(companyName)}` : null);
2150
+ if (!accountKey) {
2151
+ return null;
2152
+ }
2153
+ return {
2154
+ accountKey,
2155
+ salesNavCompanyUrl,
2156
+ linkedinCompanyUrl,
2157
+ companyId,
2158
+ companyName,
2159
+ industry: extractFirstNestedStringByKeys(element, ["industry", "industryName"]),
2160
+ location: extractFirstNestedStringByKeys(element, ["location", "locationName", "geoRegion"]),
2161
+ headquarters: extractFirstNestedStringByKeys(element, ["headquarters", "headquartersLocation"]),
2162
+ employeeCount: extractLinkedInCompanyEmployeeCountFromSalesApiElement(element),
2163
+ followerCount: extractLinkedInCompanyNumericTextFromSalesApiElement(element, ["followerCount", "followersCount", "numFollowers"], /(\d[\d.,kmbKMB+]*)\s+followers?\b/i),
2164
+ companyType: extractFirstNestedStringByKeys(element, ["companyType", "type"]),
2165
+ description: extractFirstNestedStringByKeys(element, ["description", "summary", "tagline"]),
2166
+ website: extractFirstNestedStringByKeys(element, ["website", "websiteUrl", "companyWebsite"]),
2167
+ searchQueryUrl: queryUrl,
2168
+ scrapedAt: new Date().toISOString(),
2169
+ rawLocalSalesNavigatorAccountResult: element,
2170
+ };
2171
+ }
2172
+ function normalizeLocalSalesNavigatorAccounts(responseBody, queryUrl) {
2173
+ if (typeof responseBody !== "object" ||
2174
+ responseBody === null ||
2175
+ Array.isArray(responseBody)) {
2176
+ return [];
2177
+ }
2178
+ const elements = responseBody.elements;
2179
+ if (!Array.isArray(elements)) {
2180
+ return [];
2181
+ }
2182
+ const accounts = [];
2183
+ const seen = new Set();
2184
+ for (const element of elements) {
2185
+ if (typeof element !== "object" || element === null || Array.isArray(element)) {
2186
+ continue;
2187
+ }
2188
+ const account = normalizeLocalSalesNavigatorAccount(element, queryUrl);
2189
+ if (!account || seen.has(account.accountKey)) {
2190
+ continue;
2191
+ }
2192
+ seen.add(account.accountKey);
2193
+ accounts.push(account);
2194
+ }
2195
+ return accounts;
2196
+ }
2019
2197
  function buildLinkedInCompanyLookupVariants(params) {
2020
2198
  const variants = [];
2021
2199
  const seen = new Set();
@@ -2125,7 +2303,9 @@ async function resolveDirectLinkedInCompanyContexts(params) {
2125
2303
  matchedCompanyUrl = companyUrl ?? matchedCompanyUrl;
2126
2304
  matchedSalesNavCompanyUrl = salesNavCompanyUrl ?? matchedSalesNavCompanyUrl;
2127
2305
  matchedCompanyName = companyName ?? matchedCompanyName;
2128
- matchedCompanyEmployeeCount = extractLinkedInCompanyEmployeeCountFromSalesApiElement(first);
2306
+ matchedCompanyEmployeeCount = extractLinkedInCompanyEmployeeCountFromSalesApiElement(first, {
2307
+ allowTextFallback: true
2308
+ });
2129
2309
  addAlias(companyName);
2130
2310
  addAlias(companyUrl ? normalizeLinkedInCompanyHandle(companyUrl)?.replace(/[-_]+/g, " ") : null);
2131
2311
  addAlias(salesNavCompanyUrl ? normalizeLookupWhitespace(salesNavCompanyUrl.split("/sales/company/")[1]?.split(/[/?#]/)[0] ?? "") : null);
@@ -3320,7 +3500,9 @@ async function invokeLinkedInCompanyEnrichmentDirect(params) {
3320
3500
  matchedCompanyUrl = companyUrl;
3321
3501
  matchedSalesNavCompanyUrl = salesNavCompanyUrl;
3322
3502
  matchedCompanyName = extractLinkedInCompanyNameFromSalesApiElement(first);
3323
- matchedCompanyEmployeeCount = extractLinkedInCompanyEmployeeCountFromSalesApiElement(first);
3503
+ matchedCompanyEmployeeCount = extractLinkedInCompanyEmployeeCountFromSalesApiElement(first, {
3504
+ allowTextFallback: true
3505
+ });
3324
3506
  break;
3325
3507
  }
3326
3508
  }
@@ -4233,6 +4415,11 @@ function resolveSessionOrgId(session) {
4233
4415
  const orgId = session.user.orgId?.trim();
4234
4416
  return orgId && orgId.length > 0 ? orgId : null;
4235
4417
  }
4418
+ function resolveAccountSearchStorageOrgId(session, explicitOrgId) {
4419
+ return (compactOptionalText(explicitOrgId) ??
4420
+ compactOptionalText(process.env.SALESPROMPTER_ORG_ID) ??
4421
+ (session ? resolveSessionOrgId(session) : null));
4422
+ }
4236
4423
  function normalizeCliApiBaseUrl(value) {
4237
4424
  return value.trim().replace(/\/+$/, "");
4238
4425
  }
@@ -5791,11 +5978,18 @@ async function fetchLinkedInCompaniesBackfillStatus(session, payload) {
5791
5978
  return value;
5792
5979
  }
5793
5980
  class LocalSalesNavigatorRateLimitError extends Error {
5794
- constructor(status, bodyPreview) {
5981
+ status;
5982
+ retryAfterMs;
5983
+ constructor(status, bodyPreview, retryAfterMs) {
5795
5984
  super(`Sales Navigator request stopped because LinkedIn returned HTTP ${status}. ` +
5796
5985
  "Use LinkedIn directly in a supported browser and wait before trying again. " +
5986
+ (retryAfterMs != null
5987
+ ? `Retry after approximately ${Math.ceil(retryAfterMs / 1000)} seconds. `
5988
+ : "") +
5797
5989
  `Response preview: ${bodyPreview}`);
5798
5990
  this.name = "LocalSalesNavigatorRateLimitError";
5991
+ this.status = status;
5992
+ this.retryAfterMs = retryAfterMs;
5799
5993
  }
5800
5994
  }
5801
5995
  function stripShellQuotes(value) {
@@ -5874,7 +6068,7 @@ function buildSalesNavigatorApiRequestFromSearchUrl(searchUrl, config, count) {
5874
6068
  if (source.pathname.includes("/sales-api/salesApiLeadSearch")) {
5875
6069
  return {
5876
6070
  url: source.toString(),
5877
- headers: buildLinkedInSalesNavigatorLocalImportHeaders(config)
6071
+ headers: buildLinkedInSalesNavigatorLocalImportHeaders(config),
5878
6072
  };
5879
6073
  }
5880
6074
  if (!source.pathname.includes("/sales/search/people")) {
@@ -5886,7 +6080,8 @@ function buildSalesNavigatorApiRequestFromSearchUrl(searchUrl, config, count) {
5886
6080
  }
5887
6081
  const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
5888
6082
  `${source.protocol}//${source.host}`;
5889
- const rawSessionId = extractRawUrlSearchParam(source, "sessionId") ?? encodeURIComponent(generateLinkedInSessionId());
6083
+ const rawSessionId = extractRawUrlSearchParam(source, "sessionId") ??
6084
+ encodeURIComponent(generateLinkedInSessionId());
5890
6085
  const apiQuery = normalizeSalesNavigatorApiQueryValue(rawQuery);
5891
6086
  const normalizedCount = Math.max(1, Math.min(100, Math.trunc(count)));
5892
6087
  const apiUrl = `${baseUrl.replace(/\/+$/, "")}/sales-api/salesApiLeadSearch` +
@@ -5895,7 +6090,337 @@ function buildSalesNavigatorApiRequestFromSearchUrl(searchUrl, config, count) {
5895
6090
  "&decorationId=com.linkedin.sales.deco.desktop.searchv2.LeadSearchResult-14";
5896
6091
  return {
5897
6092
  url: apiUrl,
5898
- headers: buildLinkedInSalesNavigatorLocalImportHeaders(config)
6093
+ headers: buildLinkedInSalesNavigatorLocalImportHeaders(config),
6094
+ };
6095
+ }
6096
+ function extractSalesNavigatorDecorationId(requestUrl, fallbackDecorationId) {
6097
+ try {
6098
+ const url = new URL(requestUrl);
6099
+ return (extractRawUrlSearchParam(url, "decorationId") ??
6100
+ fallbackDecorationId);
6101
+ }
6102
+ catch {
6103
+ return fallbackDecorationId;
6104
+ }
6105
+ }
6106
+ function buildSalesNavigatorAccountApiUrlFromSearchUrl(searchUrl, count, options = {}) {
6107
+ const source = new URL(searchUrl);
6108
+ if (source.pathname.includes("/sales-api/salesApiAccountSearch")) {
6109
+ return source.toString();
6110
+ }
6111
+ if (!source.pathname.includes("/sales/search/company")) {
6112
+ throw new Error("Automatic Sales Navigator account splitting requires a /sales/search/company URL.");
6113
+ }
6114
+ const rawQuery = extractRawUrlSearchParam(source, "query");
6115
+ if (!rawQuery) {
6116
+ throw new Error("Sales Navigator account search URL is missing the query parameter.");
6117
+ }
6118
+ const baseUrl = compactOptionalText(options.baseUrl) ??
6119
+ compactOptionalText(process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL) ??
6120
+ `${source.protocol}//${source.host}`;
6121
+ const rawSessionId = extractRawUrlSearchParam(source, "sessionId") ??
6122
+ encodeURIComponent(generateLinkedInSessionId());
6123
+ const apiQuery = normalizeSalesNavigatorApiQueryValue(rawQuery);
6124
+ const normalizedCount = Math.max(1, Math.min(100, Math.trunc(count)));
6125
+ const decorationId = compactOptionalText(options.decorationId) ??
6126
+ "com.linkedin.sales.deco.desktop.searchv2.AccountSearchResult-14";
6127
+ return (`${baseUrl.replace(/\/+$/, "")}/sales-api/salesApiAccountSearch` +
6128
+ `?q=searchQuery&query=${apiQuery}&start=0&count=${normalizedCount}` +
6129
+ `&trackingParam=(sessionId:${rawSessionId})` +
6130
+ `&decorationId=${decorationId}`);
6131
+ }
6132
+ function buildSalesNavigatorAccountApiRequestFromSearchUrl(searchUrl, config, count) {
6133
+ const source = new URL(searchUrl);
6134
+ if (source.pathname.includes("/sales-api/salesApiAccountSearch")) {
6135
+ return {
6136
+ url: source.toString(),
6137
+ headers: buildLinkedInSalesNavigatorAccountSearchHeaders(config),
6138
+ };
6139
+ }
6140
+ return {
6141
+ url: buildSalesNavigatorAccountApiUrlFromSearchUrl(searchUrl, count),
6142
+ headers: buildLinkedInSalesNavigatorAccountSearchHeaders(config),
6143
+ };
6144
+ }
6145
+ function buildSalesNavigatorAccountApiRequestFromSearchUrlWithTemplate(searchUrl, templateRequest, count) {
6146
+ const templateUrl = new URL(templateRequest.url);
6147
+ return {
6148
+ url: buildSalesNavigatorAccountApiUrlFromSearchUrl(searchUrl, count, {
6149
+ baseUrl: `${templateUrl.protocol}//${templateUrl.host}`,
6150
+ decorationId: extractSalesNavigatorDecorationId(templateRequest.url, "com.linkedin.sales.deco.desktop.searchv2.AccountSearchResult-14"),
6151
+ }),
6152
+ headers: { ...templateRequest.headers },
6153
+ };
6154
+ }
6155
+ const SALES_NAVIGATOR_PROFILE_LANGUAGE_VALUES = new Map([
6156
+ ["de", { id: "de", text: "German", selectionType: "INCLUDED" }],
6157
+ ["german", { id: "de", text: "German", selectionType: "INCLUDED" }],
6158
+ ["en", { id: "en", text: "English", selectionType: "INCLUDED" }],
6159
+ ["english", { id: "en", text: "English", selectionType: "INCLUDED" }],
6160
+ ]);
6161
+ const SALES_NAVIGATOR_SENIORITY_VALUES = new Map([
6162
+ [
6163
+ "owner / partner",
6164
+ { id: "320", text: "Owner / Partner", selectionType: "INCLUDED" },
6165
+ ],
6166
+ ["owner", { id: "320", text: "Owner / Partner", selectionType: "INCLUDED" }],
6167
+ [
6168
+ "partner",
6169
+ { id: "320", text: "Owner / Partner", selectionType: "INCLUDED" },
6170
+ ],
6171
+ ["cxo", { id: "310", text: "CXO", selectionType: "INCLUDED" }],
6172
+ [
6173
+ "vice president",
6174
+ { id: "300", text: "Vice President", selectionType: "INCLUDED" },
6175
+ ],
6176
+ ["vp", { id: "300", text: "Vice President", selectionType: "INCLUDED" }],
6177
+ ["director", { id: "220", text: "Director", selectionType: "INCLUDED" }],
6178
+ [
6179
+ "experienced manager",
6180
+ { id: "210", text: "Experienced Manager", selectionType: "INCLUDED" },
6181
+ ],
6182
+ [
6183
+ "manager",
6184
+ { id: "210", text: "Experienced Manager", selectionType: "INCLUDED" },
6185
+ ],
6186
+ ["senior", { id: "120", text: "Senior", selectionType: "INCLUDED" }],
6187
+ [
6188
+ "entry level",
6189
+ { id: "110", text: "Entry Level", selectionType: "INCLUDED" },
6190
+ ],
6191
+ ["entry", { id: "110", text: "Entry Level", selectionType: "INCLUDED" }],
6192
+ ]);
6193
+ const SALES_NAVIGATOR_FUNCTION_VALUES = new Map([
6194
+ [
6195
+ "human resources",
6196
+ { id: "12", text: "Human Resources", selectionType: "INCLUDED" },
6197
+ ],
6198
+ ["hr", { id: "12", text: "Human Resources", selectionType: "INCLUDED" }],
6199
+ ]);
6200
+ const SALES_NAVIGATOR_COMPANY_TYPE_VALUES = new Map([
6201
+ [
6202
+ "privately held",
6203
+ { id: "P", text: "Privately Held", selectionType: "INCLUDED" },
6204
+ ],
6205
+ ["private", { id: "P", text: "Privately Held", selectionType: "INCLUDED" }],
6206
+ [
6207
+ "public company",
6208
+ { id: "C", text: "Public Company", selectionType: "INCLUDED" },
6209
+ ],
6210
+ ["public", { id: "C", text: "Public Company", selectionType: "INCLUDED" }],
6211
+ ]);
6212
+ const SALES_NAVIGATOR_COMPANY_HEADCOUNT_VALUES = new Map([
6213
+ ["1-10", { id: "B", text: "1-10", selectionType: "INCLUDED" }],
6214
+ ["11-50", { id: "C", text: "11-50", selectionType: "INCLUDED" }],
6215
+ ["51-200", { id: "D", text: "51-200", selectionType: "INCLUDED" }],
6216
+ ["201-500", { id: "E", text: "201-500", selectionType: "INCLUDED" }],
6217
+ ["501-1000", { id: "F", text: "501-1000", selectionType: "INCLUDED" }],
6218
+ ["1001-5000", { id: "G", text: "1001-5000", selectionType: "INCLUDED" }],
6219
+ ["5001-10,000", { id: "H", text: "5001-10,000", selectionType: "INCLUDED" }],
6220
+ ["5001-10000", { id: "H", text: "5001-10,000", selectionType: "INCLUDED" }],
6221
+ ["10,000+", { id: "I", text: "10,000+", selectionType: "INCLUDED" }],
6222
+ ["10000+", { id: "I", text: "10,000+", selectionType: "INCLUDED" }],
6223
+ ]);
6224
+ const SALES_NAVIGATOR_TENURE_VALUES = new Map([
6225
+ [
6226
+ "less than 1 year",
6227
+ { id: "1", text: "Less than 1 year", selectionType: "INCLUDED" },
6228
+ ],
6229
+ [
6230
+ "1 to 2 years",
6231
+ { id: "2", text: "1 to 2 years", selectionType: "INCLUDED" },
6232
+ ],
6233
+ ["1-2 years", { id: "2", text: "1 to 2 years", selectionType: "INCLUDED" }],
6234
+ [
6235
+ "3 to 5 years",
6236
+ { id: "3", text: "3 to 5 years", selectionType: "INCLUDED" },
6237
+ ],
6238
+ ["3-5 years", { id: "3", text: "3 to 5 years", selectionType: "INCLUDED" }],
6239
+ [
6240
+ "6 to 10 years",
6241
+ { id: "4", text: "6 to 10 years", selectionType: "INCLUDED" },
6242
+ ],
6243
+ ["6-10 years", { id: "4", text: "6 to 10 years", selectionType: "INCLUDED" }],
6244
+ [
6245
+ "more than 10 years",
6246
+ { id: "5", text: "More than 10 years", selectionType: "INCLUDED" },
6247
+ ],
6248
+ [
6249
+ "10+ years",
6250
+ { id: "5", text: "More than 10 years", selectionType: "INCLUDED" },
6251
+ ],
6252
+ ]);
6253
+ const SALES_NAVIGATOR_REGION_VALUES = new Map([
6254
+ ["europe", { id: "100506914", text: "Europe", selectionType: "INCLUDED" }],
6255
+ ["germany", { id: "101282230", text: "Germany", selectionType: "INCLUDED" }],
6256
+ ]);
6257
+ const SALES_NAVIGATOR_INDUSTRY_VALUES = new Map([
6258
+ [
6259
+ "architecture and planning",
6260
+ { id: "50", text: "Architecture and Planning", selectionType: "INCLUDED" },
6261
+ ],
6262
+ [
6263
+ "machinery manufacturing",
6264
+ { id: "55", text: "Machinery Manufacturing", selectionType: "INCLUDED" },
6265
+ ],
6266
+ ]);
6267
+ function normalizeLeadParameterKey(value) {
6268
+ return value.trim().toLowerCase().replace(/\s+/g, " ");
6269
+ }
6270
+ function splitName(fullName) {
6271
+ const parts = fullName.trim().split(/\s+/).filter(Boolean);
6272
+ return {
6273
+ firstName: parts[0] ?? "",
6274
+ lastName: parts.slice(1).join(" "),
6275
+ };
6276
+ }
6277
+ function buildGermanNameVariants(value) {
6278
+ const trimmed = value.trim();
6279
+ if (!trimmed) {
6280
+ return [];
6281
+ }
6282
+ const variants = new Set([trimmed]);
6283
+ const replacements = [
6284
+ [/ae/g, "ä"],
6285
+ [/Ae/g, "Ä"],
6286
+ [/oe/g, "ö"],
6287
+ [/Oe/g, "Ö"],
6288
+ [/ue/g, "ü"],
6289
+ [/Ue/g, "Ü"],
6290
+ [/ss/g, "ß"],
6291
+ ];
6292
+ let current = trimmed;
6293
+ for (const [pattern, replacement] of replacements) {
6294
+ current = current.replace(pattern, replacement);
6295
+ }
6296
+ variants.add(current);
6297
+ return [...variants].filter((variant) => variant.length > 0);
6298
+ }
6299
+ function parseSalesNavigatorValueSpec(spec, lookup) {
6300
+ const trimmed = spec.trim();
6301
+ if (!trimmed) {
6302
+ throw new Error("Filter value cannot be empty.");
6303
+ }
6304
+ const mapped = lookup?.get(normalizeLeadParameterKey(trimmed));
6305
+ if (mapped) {
6306
+ return { ...mapped };
6307
+ }
6308
+ const pipeIndex = trimmed.indexOf("|");
6309
+ if (pipeIndex > 0 && trimmed.slice(pipeIndex + 1).trim()) {
6310
+ return {
6311
+ id: trimmed.slice(0, pipeIndex).trim(),
6312
+ text: trimmed.slice(pipeIndex + 1).trim(),
6313
+ selectionType: "INCLUDED",
6314
+ };
6315
+ }
6316
+ const parts = trimmed.split(":");
6317
+ if (parts.length >= 2 &&
6318
+ parts[0]?.trim() &&
6319
+ parts.slice(1).join(":").trim()) {
6320
+ return {
6321
+ id: parts[0]?.trim(),
6322
+ text: parts.slice(1).join(":").trim(),
6323
+ selectionType: "INCLUDED",
6324
+ };
6325
+ }
6326
+ return {
6327
+ text: trimmed,
6328
+ selectionType: "INCLUDED",
6329
+ };
6330
+ }
6331
+ function parseGenericLeadParameterFilter(spec) {
6332
+ const separatorIndex = spec.indexOf(":");
6333
+ if (separatorIndex <= 0) {
6334
+ throw new Error(`Invalid --filter value "${spec}". Use TYPE:text or TYPE:id:text.`);
6335
+ }
6336
+ const type = spec.slice(0, separatorIndex).trim().toUpperCase();
6337
+ const valueSpec = spec.slice(separatorIndex + 1).trim();
6338
+ if (!type || !valueSpec) {
6339
+ throw new Error(`Invalid --filter value "${spec}". Use TYPE:text or TYPE:id:text.`);
6340
+ }
6341
+ return {
6342
+ type,
6343
+ values: [parseSalesNavigatorValueSpec(valueSpec)],
6344
+ };
6345
+ }
6346
+ function buildSingleValueLeadParameterFilter(type, value, lookup) {
6347
+ const trimmed = value?.trim();
6348
+ if (!trimmed) {
6349
+ return null;
6350
+ }
6351
+ return {
6352
+ type,
6353
+ values: [parseSalesNavigatorValueSpec(trimmed, lookup)],
6354
+ };
6355
+ }
6356
+ function appendSalesNavigatorKeywords(searchUrl, keywords) {
6357
+ const trimmed = keywords?.trim();
6358
+ if (!trimmed) {
6359
+ return searchUrl;
6360
+ }
6361
+ const url = new URL(searchUrl);
6362
+ const query = url.searchParams.get("query");
6363
+ if (!query) {
6364
+ return searchUrl;
6365
+ }
6366
+ const withoutClosingParen = query.endsWith(")") ? query.slice(0, -1) : query;
6367
+ url.searchParams.set("query", `${withoutClosingParen},keywords:${trimmed})`);
6368
+ return url.toString();
6369
+ }
6370
+ function buildLeadParameterSearchUrl(filters, keywords) {
6371
+ const url = new URL(appendSalesNavigatorKeywords(buildSalesNavigatorPeopleSearchUrl(filters), keywords));
6372
+ const query = url.searchParams.get("query");
6373
+ if (query) {
6374
+ const inner = query.startsWith("(") && query.endsWith(")") ? query.slice(1, -1) : query;
6375
+ url.searchParams.set("query", `(spellCorrectionEnabled:true,recentSearchParam:(id:${Date.now()},doLogHistory:true),${inner})`);
6376
+ }
6377
+ url.searchParams.set("sessionId", generateLinkedInSessionId());
6378
+ url.searchParams.set("viewAllFilters", "true");
6379
+ return url.toString();
6380
+ }
6381
+ function normalizeGermanNameForMatch(value) {
6382
+ return value
6383
+ .trim()
6384
+ .toLowerCase()
6385
+ .replace(/ä/g, "ae")
6386
+ .replace(/ö/g, "oe")
6387
+ .replace(/ü/g, "ue")
6388
+ .replace(/ß/g, "ss")
6389
+ .replace(/\s+/g, " ");
6390
+ }
6391
+ function contactMatchesLocalLead(person, target) {
6392
+ const fullName = normalizeGermanNameForMatch(String(person.fullName ?? ""));
6393
+ const firstName = target.firstName
6394
+ ? normalizeGermanNameForMatch(target.firstName)
6395
+ : null;
6396
+ const lastName = target.lastName
6397
+ ? normalizeGermanNameForMatch(target.lastName)
6398
+ : null;
6399
+ const company = target.company?.trim().toLowerCase();
6400
+ const personCompany = String(person.companyName ?? "").toLowerCase();
6401
+ if (firstName && !fullName.includes(firstName)) {
6402
+ return false;
6403
+ }
6404
+ if (lastName && !fullName.includes(lastName)) {
6405
+ return false;
6406
+ }
6407
+ if (company && personCompany && !personCompany.includes(company)) {
6408
+ return false;
6409
+ }
6410
+ return true;
6411
+ }
6412
+ function summarizeMatchedLeadParameterResult(person) {
6413
+ if (!person) {
6414
+ return null;
6415
+ }
6416
+ return {
6417
+ fullName: typeof person.fullName === "string" ? person.fullName : null,
6418
+ title: typeof person.title === "string" ? person.title : null,
6419
+ companyName: typeof person.companyName === "string" ? person.companyName : null,
6420
+ profileUrl: typeof person.profileUrl === "string" ? person.profileUrl : null,
6421
+ linkedInProfileUrl: typeof person.linkedInProfileUrl === "string"
6422
+ ? person.linkedInProfileUrl
6423
+ : null,
5899
6424
  };
5900
6425
  }
5901
6426
  function withSalesNavigatorPaging(url, start, count) {
@@ -5910,9 +6435,7 @@ function withSalesNavigatorPaging(url, start, count) {
5910
6435
  }
5911
6436
  const base = withoutHash.slice(0, queryIndex);
5912
6437
  const search = withoutHash.slice(queryIndex + 1);
5913
- const params = search
5914
- .split("&")
5915
- .filter((part) => {
6438
+ const params = search.split("&").filter((part) => {
5916
6439
  const key = part.split("=", 1)[0]?.toLowerCase();
5917
6440
  return key !== "start" && key !== "count";
5918
6441
  });
@@ -5926,13 +6449,28 @@ function buildLinkedInSalesNavigatorLocalImportHeaders(config) {
5926
6449
  cookie: config.cookie,
5927
6450
  "user-agent": config.userAgent,
5928
6451
  "x-li-identity": config.identity,
5929
- "x-restli-protocol-version": "2.0.0"
6452
+ "x-restli-protocol-version": "2.0.0",
6453
+ };
6454
+ }
6455
+ function buildLinkedInSalesNavigatorAccountSearchHeaders(config) {
6456
+ return {
6457
+ ...buildLinkedInSalesNavigatorLocalImportHeaders(config),
6458
+ accept: "*/*",
6459
+ "accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
6460
+ referer: "https://www.linkedin.com/sales/search/company",
6461
+ "sec-fetch-dest": "empty",
6462
+ "sec-fetch-mode": "cors",
6463
+ "sec-fetch-site": "same-origin",
6464
+ "x-li-lang": "en_US",
6465
+ "x-li-page-instance": "urn:li:page:d_sales2_search_accounts;13Jvve6kRGCao+iP0wwAag==",
5930
6466
  };
5931
6467
  }
5932
6468
  function getNestedValue(record, pathParts) {
5933
6469
  let current = record;
5934
6470
  for (const part of pathParts) {
5935
- if (typeof current !== "object" || current === null || Array.isArray(current)) {
6471
+ if (typeof current !== "object" ||
6472
+ current === null ||
6473
+ Array.isArray(current)) {
5936
6474
  return undefined;
5937
6475
  }
5938
6476
  current = current[part];
@@ -5953,8 +6491,14 @@ function flattenSalesNavigatorRecord(value) {
5953
6491
  return {};
5954
6492
  }
5955
6493
  const record = value;
5956
- const entity = (record.entityResult ?? record.lead ?? record.profile ?? record.person ?? record);
5957
- return typeof entity === "object" && entity !== null && !Array.isArray(entity) ? { ...record, ...entity } : record;
6494
+ const entity = (record.entityResult ??
6495
+ record.lead ??
6496
+ record.profile ??
6497
+ record.person ??
6498
+ record);
6499
+ return typeof entity === "object" && entity !== null && !Array.isArray(entity)
6500
+ ? { ...record, ...entity }
6501
+ : record;
5958
6502
  }
5959
6503
  function deriveLocalSalesNavigatorProfileUrl(record) {
5960
6504
  const direct = firstLocalString(record, [
@@ -5963,7 +6507,7 @@ function deriveLocalSalesNavigatorProfileUrl(record) {
5963
6507
  "linkedInProfileUrl",
5964
6508
  "navigationUrl",
5965
6509
  "url",
5966
- "profilePicture.profileUrl"
6510
+ "profilePicture.profileUrl",
5967
6511
  ]);
5968
6512
  if (direct) {
5969
6513
  try {
@@ -5978,14 +6522,23 @@ function deriveLocalSalesNavigatorProfileUrl(record) {
5978
6522
  "publicIdentifier",
5979
6523
  "public_id",
5980
6524
  "miniProfile.publicIdentifier",
5981
- "member.publicIdentifier"
6525
+ "member.publicIdentifier",
5982
6526
  ]);
5983
6527
  if (publicIdentifier) {
5984
6528
  return `https://www.linkedin.com/in/${encodeURIComponent(publicIdentifier)}`;
5985
6529
  }
5986
- const urn = firstLocalString(record, ["entityUrn", "profileUrn", "objectUrn", "memberUrn", "urn"]);
5987
- const urnId = urn?.match(/fs_salesProfile:\(([^,)]+)/i)?.[1] ?? urn?.match(/(?:profile|member|lead):([^,)]+)/i)?.[1];
5988
- return urnId ? `https://www.linkedin.com/sales/lead/${encodeURIComponent(urnId)}` : null;
6530
+ const urn = firstLocalString(record, [
6531
+ "entityUrn",
6532
+ "profileUrn",
6533
+ "objectUrn",
6534
+ "memberUrn",
6535
+ "urn",
6536
+ ]);
6537
+ const urnId = urn?.match(/fs_salesProfile:\(([^,)]+)/i)?.[1] ??
6538
+ urn?.match(/(?:profile|member|lead):([^,)]+)/i)?.[1];
6539
+ return urnId
6540
+ ? `https://www.linkedin.com/sales/lead/${encodeURIComponent(urnId)}`
6541
+ : null;
5989
6542
  }
5990
6543
  function firstLocalArrayRecord(record, key) {
5991
6544
  const value = record[key];
@@ -5997,14 +6550,21 @@ function firstLocalArrayRecord(record, key) {
5997
6550
  }
5998
6551
  function firstLocalNestedRecord(record, key) {
5999
6552
  const value = record[key];
6000
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
6553
+ return typeof value === "object" && value !== null && !Array.isArray(value)
6554
+ ? value
6555
+ : {};
6001
6556
  }
6002
6557
  function deriveLocalCompanyId(record, currentPosition, company) {
6003
- const direct = firstLocalString(record, ["companyId", "currentCompanyId", "company.id"]);
6558
+ const direct = firstLocalString(record, [
6559
+ "companyId",
6560
+ "currentCompanyId",
6561
+ "company.id",
6562
+ ]);
6004
6563
  if (direct) {
6005
6564
  return direct;
6006
6565
  }
6007
- const urn = firstLocalString(currentPosition, ["companyUrn"]) ?? firstLocalString(company, ["entityUrn"]);
6566
+ const urn = firstLocalString(currentPosition, ["companyUrn"]) ??
6567
+ firstLocalString(company, ["entityUrn"]);
6008
6568
  return urn?.match(/:(\d+)$/)?.[1] ?? null;
6009
6569
  }
6010
6570
  function formatSalesNavigatorTenure(value) {
@@ -6012,8 +6572,12 @@ function formatSalesNavigatorTenure(value) {
6012
6572
  return null;
6013
6573
  }
6014
6574
  const record = value;
6015
- const years = typeof record.numYears === "number" && Number.isFinite(record.numYears) ? `${record.numYears}y` : null;
6016
- const months = typeof record.numMonths === "number" && Number.isFinite(record.numMonths) ? `${record.numMonths}m` : null;
6575
+ const years = typeof record.numYears === "number" && Number.isFinite(record.numYears)
6576
+ ? `${record.numYears}y`
6577
+ : null;
6578
+ const months = typeof record.numMonths === "number" && Number.isFinite(record.numMonths)
6579
+ ? `${record.numMonths}m`
6580
+ : null;
6017
6581
  return [years, months].filter(Boolean).join(" ") || null;
6018
6582
  }
6019
6583
  function buildLinkedInImageUrl(value) {
@@ -6028,16 +6592,22 @@ function buildLinkedInImageUrl(value) {
6028
6592
  const artifact = record.artifacts
6029
6593
  .filter((item) => typeof item === "object" && item !== null && !Array.isArray(item))
6030
6594
  .sort((left, right) => Number(right.width ?? 0) - Number(left.width ?? 0))[0];
6031
- const pathSegment = typeof artifact?.fileIdentifyingUrlPathSegment === "string" ? artifact.fileIdentifyingUrlPathSegment : null;
6595
+ const pathSegment = typeof artifact?.fileIdentifyingUrlPathSegment === "string"
6596
+ ? artifact.fileIdentifyingUrlPathSegment
6597
+ : null;
6032
6598
  return pathSegment ? `${rootUrl}${pathSegment}` : null;
6033
6599
  }
6034
6600
  function deriveSharedConnectionsCount(record) {
6035
- const badges = Array.isArray(record.spotlightBadges) ? record.spotlightBadges : [];
6601
+ const badges = Array.isArray(record.spotlightBadges)
6602
+ ? record.spotlightBadges
6603
+ : [];
6036
6604
  for (const badge of badges) {
6037
6605
  if (typeof badge !== "object" || badge === null || Array.isArray(badge)) {
6038
6606
  continue;
6039
6607
  }
6040
- const displayValue = firstLocalString(badge, ["displayValue"]);
6608
+ const displayValue = firstLocalString(badge, [
6609
+ "displayValue",
6610
+ ]);
6041
6611
  const match = displayValue?.match(/(\d+)\s+mutual connection/i);
6042
6612
  if (match) {
6043
6613
  return Number(match[1]);
@@ -6051,13 +6621,19 @@ function deriveLocalSalesNavigatorFullName(record) {
6051
6621
  "name",
6052
6622
  "title.text",
6053
6623
  "profileName",
6054
- "miniProfile.fullName"
6624
+ "miniProfile.fullName",
6055
6625
  ]);
6056
6626
  if (direct) {
6057
6627
  return direct;
6058
6628
  }
6059
- const firstName = firstLocalString(record, ["firstName", "miniProfile.firstName"]);
6060
- const lastName = firstLocalString(record, ["lastName", "miniProfile.lastName"]);
6629
+ const firstName = firstLocalString(record, [
6630
+ "firstName",
6631
+ "miniProfile.firstName",
6632
+ ]);
6633
+ const lastName = firstLocalString(record, [
6634
+ "lastName",
6635
+ "miniProfile.lastName",
6636
+ ]);
6061
6637
  return [firstName, lastName].filter(Boolean).join(" ").trim() || null;
6062
6638
  }
6063
6639
  function collectPotentialSalesNavigatorRows(value, rows = []) {
@@ -6073,7 +6649,12 @@ function collectPotentialSalesNavigatorRows(value, rows = []) {
6073
6649
  const record = flattenSalesNavigatorRecord(value);
6074
6650
  if (deriveLocalSalesNavigatorProfileUrl(record) &&
6075
6651
  (deriveLocalSalesNavigatorFullName(record) ||
6076
- firstLocalString(record, ["occupation", "title", "headline", "currentPositions.0.title"]))) {
6652
+ firstLocalString(record, [
6653
+ "occupation",
6654
+ "title",
6655
+ "headline",
6656
+ "currentPositions.0.title",
6657
+ ]))) {
6077
6658
  rows.push(record);
6078
6659
  }
6079
6660
  for (const nested of Object.values(value)) {
@@ -6101,44 +6682,75 @@ function normalizeLocalSalesNavigatorPeople(responseBody, queryUrl) {
6101
6682
  defaultProfileUrl: profileUrl,
6102
6683
  linkedInProfileUrl: profileUrl.includes("/in/") ? profileUrl : null,
6103
6684
  fullName,
6104
- firstName: firstLocalString(record, ["firstName", "miniProfile.firstName"]),
6685
+ firstName: firstLocalString(record, [
6686
+ "firstName",
6687
+ "miniProfile.firstName",
6688
+ ]),
6105
6689
  lastName: firstLocalString(record, ["lastName", "miniProfile.lastName"]),
6106
- title: firstLocalString(record, ["occupation", "title", "headline"]) ?? firstLocalString(currentPosition, ["title"]),
6107
- companyName: firstLocalString(record, [
6108
- "companyName",
6109
- "currentCompanyName"
6110
- ]) ?? firstLocalString(currentPosition, ["companyName"]) ?? firstLocalString(company, ["name"]),
6690
+ title: firstLocalString(record, ["occupation", "title", "headline"]) ??
6691
+ firstLocalString(currentPosition, ["title"]),
6692
+ companyName: firstLocalString(record, ["companyName", "currentCompanyName"]) ??
6693
+ firstLocalString(currentPosition, ["companyName"]) ??
6694
+ firstLocalString(company, ["name"]),
6111
6695
  companyId,
6112
- companyUrl: firstLocalString(record, ["companyUrl", "company.url"]) ?? (companyId ? `https://www.linkedin.com/sales/company/${companyId}` : null),
6113
- regularCompanyUrl: companyId ? `https://www.linkedin.com/company/${companyId}` : null,
6114
- location: firstLocalString(record, ["location", "geoRegion", "locationName"]),
6115
- industry: firstLocalString(record, ["industry"]) ?? firstLocalString(company, ["industry"]),
6696
+ companyUrl: firstLocalString(record, ["companyUrl", "company.url"]) ??
6697
+ (companyId
6698
+ ? `https://www.linkedin.com/sales/company/${companyId}`
6699
+ : null),
6700
+ regularCompanyUrl: companyId
6701
+ ? `https://www.linkedin.com/company/${companyId}`
6702
+ : null,
6703
+ location: firstLocalString(record, [
6704
+ "location",
6705
+ "geoRegion",
6706
+ "locationName",
6707
+ ]),
6708
+ industry: firstLocalString(record, ["industry"]) ??
6709
+ firstLocalString(company, ["industry"]),
6116
6710
  summary: firstLocalString(record, ["summary", "description"]),
6117
- titleDescription: firstLocalString(record, ["titleDescription"]) ?? firstLocalString(currentPosition, ["description"]),
6118
- companyLocation: firstLocalString(record, ["companyLocation"]) ?? firstLocalString(company, ["location"]),
6119
- durationInRole: firstLocalString(record, ["durationInRole"]) ?? formatSalesNavigatorTenure(currentPosition.tenureAtPosition),
6120
- durationInCompany: firstLocalString(record, ["durationInCompany"]) ?? formatSalesNavigatorTenure(currentPosition.tenureAtCompany),
6121
- connectionDegree: firstLocalString(record, ["connectionDegree"]) ?? (record.degree != null ? String(record.degree) : null),
6711
+ titleDescription: firstLocalString(record, ["titleDescription"]) ??
6712
+ firstLocalString(currentPosition, ["description"]),
6713
+ companyLocation: firstLocalString(record, ["companyLocation"]) ??
6714
+ firstLocalString(company, ["location"]),
6715
+ durationInRole: firstLocalString(record, ["durationInRole"]) ??
6716
+ formatSalesNavigatorTenure(currentPosition.tenureAtPosition),
6717
+ durationInCompany: firstLocalString(record, ["durationInCompany"]) ??
6718
+ formatSalesNavigatorTenure(currentPosition.tenureAtCompany),
6719
+ connectionDegree: firstLocalString(record, ["connectionDegree"]) ??
6720
+ (record.degree != null ? String(record.degree) : null),
6122
6721
  sharedConnectionsCount: deriveSharedConnectionsCount(record),
6123
- profileImageUrl: firstLocalString(record, ["profileImageUrl", "pictureUrl", "profilePicture.displayImage"]) ?? buildLinkedInImageUrl(record.profilePictureDisplayImage),
6722
+ profileImageUrl: firstLocalString(record, [
6723
+ "profileImageUrl",
6724
+ "pictureUrl",
6725
+ "profilePicture.displayImage",
6726
+ ]) ?? buildLinkedInImageUrl(record.profilePictureDisplayImage),
6124
6727
  vmid: firstLocalString(record, ["vmid", "memberId", "profileId"]),
6125
6728
  isPremium: typeof record.premium === "boolean" ? record.premium : null,
6126
6729
  isOpenLink: typeof record.openLink === "boolean" ? record.openLink : null,
6127
6730
  query: queryUrl,
6128
6731
  timestamp: new Date().toISOString(),
6129
- rawLocalSalesNavigatorResult: record
6732
+ rawLocalSalesNavigatorResult: record,
6130
6733
  });
6131
6734
  }
6132
6735
  return rows;
6133
6736
  }
6134
6737
  function extractLocalSalesNavigatorTotalResults(responseBody) {
6135
- if (typeof responseBody !== "object" || responseBody === null || Array.isArray(responseBody)) {
6738
+ if (typeof responseBody !== "object" ||
6739
+ responseBody === null ||
6740
+ Array.isArray(responseBody)) {
6136
6741
  return null;
6137
6742
  }
6138
6743
  const record = responseBody;
6139
- const candidates = [record.paging, record.metadata, record.searchResultsMetadata, record];
6744
+ const candidates = [
6745
+ record.paging,
6746
+ record.metadata,
6747
+ record.searchResultsMetadata,
6748
+ record,
6749
+ ];
6140
6750
  for (const candidate of candidates) {
6141
- if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
6751
+ if (typeof candidate !== "object" ||
6752
+ candidate === null ||
6753
+ Array.isArray(candidate)) {
6142
6754
  continue;
6143
6755
  }
6144
6756
  for (const key of ["total", "totalResults", "resultCount", "count"]) {
@@ -6161,7 +6773,27 @@ function isSalesNavigatorRateLimitStatus(status, text) {
6161
6773
  /too many requests|rate limit|unusual activity|automated activity|temporarily restricted/i.test(text));
6162
6774
  }
6163
6775
  function isRetryableLocalSalesNavigatorStatus(status) {
6164
- return status === 408 || status === 425 || status === 500 || status === 502 || status === 503 || status === 504;
6776
+ return (status === 408 ||
6777
+ status === 425 ||
6778
+ status === 500 ||
6779
+ status === 502 ||
6780
+ status === 503 ||
6781
+ status === 504);
6782
+ }
6783
+ function parseRetryAfterHeader(value) {
6784
+ const trimmed = value?.trim();
6785
+ if (!trimmed) {
6786
+ return null;
6787
+ }
6788
+ const seconds = Number(trimmed);
6789
+ if (Number.isFinite(seconds) && seconds >= 0) {
6790
+ return Math.trunc(seconds * 1000);
6791
+ }
6792
+ const dateMs = Date.parse(trimmed);
6793
+ if (Number.isFinite(dateMs)) {
6794
+ return Math.max(0, dateMs - Date.now());
6795
+ }
6796
+ return null;
6165
6797
  }
6166
6798
  async function waitWithFullJitter(baseDelayMs, maxDelayMs, attempt) {
6167
6799
  const exponentialCap = Math.min(Math.max(0, Math.trunc(maxDelayMs)), Math.max(0, Math.trunc(baseDelayMs)) * 2 ** Math.max(0, attempt));
@@ -6178,15 +6810,16 @@ async function fetchLocalSalesNavigatorRequest(parsedRequest, retryOptions) {
6178
6810
  try {
6179
6811
  const response = await fetch(parsedRequest.url, {
6180
6812
  method: "GET",
6181
- headers: parsedRequest.headers
6813
+ headers: parsedRequest.headers,
6182
6814
  });
6183
6815
  const text = await response.text();
6184
6816
  if (!response.ok) {
6185
6817
  const preview = text.slice(0, 300);
6186
6818
  if (isSalesNavigatorRateLimitStatus(response.status, text)) {
6187
- throw new LocalSalesNavigatorRateLimitError(response.status, preview);
6819
+ throw new LocalSalesNavigatorRateLimitError(response.status, preview, parseRetryAfterHeader(response.headers.get("retry-after")));
6188
6820
  }
6189
- if (isRetryableLocalSalesNavigatorStatus(response.status) && attempt < retryOptions.maxRetries) {
6821
+ if (isRetryableLocalSalesNavigatorStatus(response.status) &&
6822
+ attempt < retryOptions.maxRetries) {
6190
6823
  retryCount += 1;
6191
6824
  retryDelayMs += await waitWithFullJitter(retryOptions.retryBaseDelayMs, retryOptions.retryMaxDelayMs, attempt);
6192
6825
  continue;
@@ -6221,7 +6854,9 @@ async function fetchAllLocalSalesNavigatorPeople(parsedRequest, options) {
6221
6854
  let totalDelayMs = 0;
6222
6855
  let retryCount = 0;
6223
6856
  const pageSize = Math.max(1, Math.min(100, Math.trunc(options.pageSize)));
6224
- const requestedProfiles = options.requestedProfiles == null ? null : Math.max(1, Math.trunc(options.requestedProfiles));
6857
+ const requestedProfiles = options.requestedProfiles == null
6858
+ ? null
6859
+ : Math.max(1, Math.trunc(options.requestedProfiles));
6225
6860
  const pageDelayMinMs = Math.max(0, Math.trunc(options.pageDelayMinMs));
6226
6861
  const pageDelayMaxMs = Math.max(pageDelayMinMs, Math.trunc(options.pageDelayMaxMs));
6227
6862
  for (let start = 0;; start += pageSize) {
@@ -6232,14 +6867,15 @@ async function fetchAllLocalSalesNavigatorPeople(parsedRequest, options) {
6232
6867
  const currentCount = Math.min(pageSize, remainingRequested);
6233
6868
  const pageRequest = {
6234
6869
  ...parsedRequest,
6235
- url: withSalesNavigatorPaging(parsedRequest.url, start, currentCount)
6870
+ url: withSalesNavigatorPaging(parsedRequest.url, start, currentCount),
6236
6871
  };
6237
6872
  const response = await fetchLocalSalesNavigatorRequest(pageRequest, options.retry);
6238
6873
  retryCount += response.retryCount;
6239
6874
  totalDelayMs += response.retryDelayMs;
6240
6875
  const responseBody = response.body;
6241
6876
  fetchedPages += 1;
6242
- totalResults = totalResults ?? extractLocalSalesNavigatorTotalResults(responseBody);
6877
+ totalResults =
6878
+ totalResults ?? extractLocalSalesNavigatorTotalResults(responseBody);
6243
6879
  const pagePeople = normalizeLocalSalesNavigatorPeople(responseBody, pageRequest.url);
6244
6880
  let added = 0;
6245
6881
  for (const person of pagePeople) {
@@ -6272,85 +6908,699 @@ async function fetchAllLocalSalesNavigatorPeople(parsedRequest, options) {
6272
6908
  delayMinMs: pageDelayMinMs,
6273
6909
  delayMaxMs: pageDelayMaxMs,
6274
6910
  totalDelayMs,
6275
- retryCount
6276
- }
6277
- };
6278
- }
6279
- async function importLocalSalesNavigatorPeopleViaApp(session, payload) {
6280
- const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/local-import`, {
6281
- method: "POST",
6282
- headers: {
6283
- "Content-Type": "application/json",
6284
- Authorization: `Bearer ${currentSession.accessToken}`
6911
+ retryCount,
6285
6912
  },
6286
- body: JSON.stringify({
6287
- sourceQueryUrl: payload.sourceQueryUrl,
6288
- slicedQueryUrl: payload.sourceQueryUrl,
6289
- appliedFilters: [],
6290
- maxResultsPerSearch: payload.maxResultsPerSearch,
6291
- numberOfProfiles: payload.numberOfProfiles,
6292
- slicePreset: payload.slicePreset,
6293
- totalResults: payload.totalResults,
6294
- fetchedPages: payload.fetchedPages,
6295
- people: payload.people,
6296
- rawPayload: {
6297
- workflow: "salesnav:local-import",
6298
- localPeopleCount: payload.people.length,
6299
- fetchedPages: payload.fetchedPages,
6300
- pacing: payload.pacing
6301
- }
6302
- })
6303
- }), SalesNavigatorLocalImportResponseSchema);
6304
- return value;
6913
+ };
6305
6914
  }
6306
- async function runLeadPoolAutoProcessViaApp(session, payload) {
6307
- const url = new URL('/api/leadpool/auto-process', session.apiBaseUrl);
6308
- if (payload.clientId)
6309
- url.searchParams.set('clientId', String(payload.clientId));
6310
- url.searchParams.set('companyLimit', String(payload.companyLimit));
6311
- url.searchParams.set('contactCountryLimit', String(payload.contactCountryLimit));
6312
- url.searchParams.set('contactNameLimit', String(payload.contactNameLimit));
6313
- url.searchParams.set('hunterLimit', String(payload.hunterLimit));
6314
- url.searchParams.set('hunterConcurrency', String(payload.hunterConcurrency));
6315
- url.searchParams.set('refresh', payload.refresh ? 'true' : 'false');
6316
- url.searchParams.set('qualifiedLeads', payload.qualifiedLeads ? 'true' : 'false');
6317
- const { value } = await fetchCliJson(session, (currentSession) => fetch(url.toString(), {
6318
- method: "POST",
6319
- headers: {
6320
- Authorization: `Bearer ${currentSession.accessToken}`
6915
+ async function fetchAllLocalSalesNavigatorAccounts(parsedRequest, options) {
6916
+ const accounts = [];
6917
+ const seen = new Set();
6918
+ let totalResults = null;
6919
+ let fetchedPages = 0;
6920
+ let totalDelayMs = 0;
6921
+ let retryCount = 0;
6922
+ const pageSize = Math.max(1, Math.min(100, Math.trunc(options.pageSize)));
6923
+ const requestedAccounts = options.requestedAccounts == null
6924
+ ? null
6925
+ : Math.max(1, Math.trunc(options.requestedAccounts));
6926
+ const pageDelayMinMs = Math.max(0, Math.trunc(options.pageDelayMinMs));
6927
+ const pageDelayMaxMs = Math.max(pageDelayMinMs, Math.trunc(options.pageDelayMaxMs));
6928
+ const startOffset = Math.max(0, Math.trunc(options.startOffset ?? 0));
6929
+ let rateLimit;
6930
+ let pageError;
6931
+ let stopReason = null;
6932
+ let lastSuccessfulStart = null;
6933
+ let lastSuccessfulCount = 0;
6934
+ let lastSuccessfulAdded = 0;
6935
+ let stoppedAtStart = null;
6936
+ let stoppedAtCount = null;
6937
+ let nextStart = null;
6938
+ for (let start = startOffset;; start += pageSize) {
6939
+ const remainingRequested = requestedAccounts == null ? pageSize : requestedAccounts - accounts.length;
6940
+ if (remainingRequested <= 0) {
6941
+ stopReason = "requested_limit_reached";
6942
+ nextStart = start;
6943
+ break;
6321
6944
  }
6322
- }), LeadPoolAutoProcessResponseSchema);
6323
- return value;
6324
- }
6325
- async function launchAffiliateCampaignViaApp(session, payload) {
6326
- const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/affiliate-campaigns`, {
6327
- method: "POST",
6328
- headers: {
6329
- "Content-Type": "application/json",
6330
- Authorization: `Bearer ${currentSession.accessToken}`
6945
+ const currentCount = Math.min(pageSize, remainingRequested);
6946
+ const pageRequest = {
6947
+ ...parsedRequest,
6948
+ url: withSalesNavigatorPaging(parsedRequest.url, start, currentCount),
6949
+ };
6950
+ let response;
6951
+ try {
6952
+ response = await fetchLocalSalesNavigatorRequest(pageRequest, options.retry);
6953
+ }
6954
+ catch (error) {
6955
+ if (error instanceof LocalSalesNavigatorRateLimitError &&
6956
+ accounts.length > 0) {
6957
+ rateLimit = {
6958
+ status: error.status,
6959
+ retryAfterMs: error.retryAfterMs,
6960
+ message: error.message,
6961
+ };
6962
+ stopReason = "rate_limit";
6963
+ stoppedAtStart = start;
6964
+ stoppedAtCount = currentCount;
6965
+ nextStart = start;
6966
+ break;
6967
+ }
6968
+ if (options.allowPartialPageErrors && accounts.length > 0) {
6969
+ pageError = {
6970
+ message: error instanceof Error ? error.message : String(error),
6971
+ };
6972
+ stopReason = "page_error";
6973
+ stoppedAtStart = start;
6974
+ stoppedAtCount = currentCount;
6975
+ nextStart = start;
6976
+ break;
6977
+ }
6978
+ throw error;
6979
+ }
6980
+ retryCount += response.retryCount;
6981
+ totalDelayMs += response.retryDelayMs;
6982
+ const responseBody = response.body;
6983
+ fetchedPages += 1;
6984
+ totalResults =
6985
+ totalResults ?? extractLocalSalesNavigatorTotalResults(responseBody);
6986
+ if (totalResults != null &&
6987
+ totalResults > Math.max(1, Math.trunc(options.maxAllowedResults))) {
6988
+ throw new SalesNavigatorSliceTooBroadError(`Account slice has ${totalResults} results, above ${options.maxAllowedResults}.`, { totalResults });
6989
+ }
6990
+ const pageAccounts = normalizeLocalSalesNavigatorAccounts(responseBody, pageRequest.url);
6991
+ let added = 0;
6992
+ for (const account of pageAccounts) {
6993
+ if (seen.has(account.accountKey)) {
6994
+ continue;
6995
+ }
6996
+ seen.add(account.accountKey);
6997
+ accounts.push(account);
6998
+ added += 1;
6999
+ if (requestedAccounts != null && accounts.length >= requestedAccounts) {
7000
+ break;
7001
+ }
7002
+ }
7003
+ if (added > 0) {
7004
+ lastSuccessfulStart = start;
7005
+ lastSuccessfulCount = currentCount;
7006
+ lastSuccessfulAdded = added;
7007
+ }
7008
+ if (requestedAccounts != null && accounts.length >= requestedAccounts) {
7009
+ stopReason = "requested_limit_reached";
7010
+ stoppedAtStart = start;
7011
+ stoppedAtCount = currentCount;
7012
+ nextStart = start + currentCount;
7013
+ break;
7014
+ }
7015
+ const reportedTotalReached = totalResults != null && start + currentCount >= totalResults;
7016
+ if (pageAccounts.length === 0) {
7017
+ stopReason = "empty_page";
7018
+ stoppedAtStart = start;
7019
+ stoppedAtCount = currentCount;
7020
+ nextStart = start;
7021
+ break;
7022
+ }
7023
+ if (added === 0) {
7024
+ stopReason = "duplicate_page";
7025
+ stoppedAtStart = start;
7026
+ stoppedAtCount = currentCount;
7027
+ nextStart = start;
7028
+ break;
7029
+ }
7030
+ if (reportedTotalReached) {
7031
+ stopReason = "reported_total_reached";
7032
+ stoppedAtStart = start;
7033
+ stoppedAtCount = currentCount;
7034
+ nextStart = start + currentCount;
7035
+ break;
7036
+ }
7037
+ const pageDelayMs = randomIntegerBetween(pageDelayMinMs, pageDelayMaxMs);
7038
+ if (pageDelayMs > 0) {
7039
+ totalDelayMs += pageDelayMs;
7040
+ await delay(pageDelayMs);
7041
+ }
7042
+ }
7043
+ const fetchedThrough = nextStart ?? (lastSuccessfulStart == null ? startOffset : lastSuccessfulStart + lastSuccessfulCount);
7044
+ const missingFromReported = totalResults == null ? null : Math.max(0, totalResults - fetchedThrough);
7045
+ return {
7046
+ accounts,
7047
+ totalResults,
7048
+ fetchedPages,
7049
+ sourceQueryUrl: parsedRequest.url,
7050
+ pacing: {
7051
+ startOffset,
7052
+ delayMinMs: pageDelayMinMs,
7053
+ delayMaxMs: pageDelayMaxMs,
7054
+ totalDelayMs,
7055
+ retryCount,
7056
+ ...(rateLimit ? { rateLimit } : {}),
7057
+ ...(pageError ? { pageError } : {}),
7058
+ stop: {
7059
+ reason: stopReason ?? "reported_total_reached",
7060
+ startOffset,
7061
+ pageSize,
7062
+ lastSuccessfulStart,
7063
+ lastSuccessfulCount,
7064
+ lastSuccessfulAdded,
7065
+ stoppedAtStart,
7066
+ stoppedAtCount,
7067
+ nextStart,
7068
+ fetchedAccounts: accounts.length,
7069
+ reportedTotal: totalResults,
7070
+ missingFromReported,
7071
+ },
6331
7072
  },
6332
- body: JSON.stringify(payload)
6333
- }), AffiliateCampaignLaunchResponseSchema);
7073
+ };
7074
+ }
7075
+ function describeLocalSalesNavigatorAccountPartialError(fetchResult) {
7076
+ const rateLimit = fetchResult.pacing.rateLimit;
7077
+ if (rateLimit) {
7078
+ return rateLimit.message;
7079
+ }
7080
+ const pageError = fetchResult.pacing.pageError;
7081
+ if (pageError) {
7082
+ return pageError.message;
7083
+ }
7084
+ const stop = fetchResult.pacing.stop;
7085
+ if (stop.reason === "requested_limit_reached") {
7086
+ return null;
7087
+ }
7088
+ if (stop.missingFromReported != null && stop.missingFromReported > 0) {
7089
+ return `Account slice stopped at offset ${stop.stoppedAtStart ?? stop.nextStart ?? "unknown"} (${stop.reason}) after fetching ${stop.fetchedAccounts} of ${stop.reportedTotal} reported accounts.`;
7090
+ }
7091
+ return null;
7092
+ }
7093
+ async function importLocalSalesNavigatorPeopleViaApp(session, payload) {
7094
+ const { value } = await fetchCliJson(session, (currentSession) => {
7095
+ const workspaceClientId = currentSession.user.workspaceClientId?.trim() || null;
7096
+ return fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/local-import`, {
7097
+ method: "POST",
7098
+ headers: {
7099
+ "Content-Type": "application/json",
7100
+ Authorization: `Bearer ${currentSession.accessToken}`,
7101
+ },
7102
+ body: JSON.stringify({
7103
+ sourceQueryUrl: payload.sourceQueryUrl,
7104
+ slicedQueryUrl: payload.sourceQueryUrl,
7105
+ appliedFilters: [],
7106
+ maxResultsPerSearch: payload.maxResultsPerSearch,
7107
+ numberOfProfiles: payload.numberOfProfiles,
7108
+ slicePreset: payload.slicePreset,
7109
+ totalResults: payload.totalResults,
7110
+ fetchedPages: payload.fetchedPages,
7111
+ people: payload.people,
7112
+ rawPayload: {
7113
+ clientId: workspaceClientId ?? undefined,
7114
+ client_id: workspaceClientId ?? undefined,
7115
+ leadList: workspaceClientId
7116
+ ? { clientId: workspaceClientId }
7117
+ : undefined,
7118
+ workflow: "salesnav:local-import",
7119
+ localPeopleCount: payload.people.length,
7120
+ fetchedPages: payload.fetchedPages,
7121
+ pacing: payload.pacing,
7122
+ },
7123
+ }),
7124
+ });
7125
+ }, SalesNavigatorLocalImportResponseSchema);
6334
7126
  return value;
6335
7127
  }
6336
- async function runLeadPoolEmailFinderViaApp(session, payload) {
6337
- const url = new URL('/api/leadpool/process-hunter-emails', session.apiBaseUrl);
6338
- url.searchParams.set('provider', payload.provider);
6339
- url.searchParams.set('limit', String(payload.limit));
6340
- if (payload.concurrency != null) {
6341
- url.searchParams.set('concurrency', String(payload.concurrency));
7128
+ async function createSalesNavigatorAccountSearchStore() {
7129
+ const databaseUrl = process.env.SALESPROMPTER_DATABASE_URL?.trim() ||
7130
+ process.env.DATABASE_URL?.trim() ||
7131
+ "";
7132
+ if (databaseUrl) {
7133
+ const client = new pg.Client({
7134
+ connectionString: databaseUrl,
7135
+ ssl: { rejectUnauthorized: false },
7136
+ });
7137
+ await client.connect();
7138
+ return { kind: "postgres", client };
7139
+ }
7140
+ const config = resolveSalesNavigatorSupabaseConfig(process.env);
7141
+ return {
7142
+ kind: "supabase",
7143
+ client: createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
7144
+ auth: {
7145
+ persistSession: false,
7146
+ autoRefreshToken: false,
7147
+ },
7148
+ }),
7149
+ };
7150
+ }
7151
+ async function closeSalesNavigatorAccountSearchStore(store) {
7152
+ if (store.kind === "postgres") {
7153
+ await store.client.end();
7154
+ }
7155
+ }
7156
+ async function createSalesNavigatorAccountSearchRun(params) {
7157
+ if (params.store.kind === "postgres") {
7158
+ const result = await params.store.client.query(`INSERT INTO public.linkedin_sales_nav_account_search_runs (
7159
+ org_id, source_query_url, slice_preset, max_results_per_search, page_size, status, raw_payload
7160
+ )
7161
+ VALUES ($1, $2, $3, $4, $5, 'running', $6::jsonb)
7162
+ RETURNING id`, [
7163
+ params.orgId,
7164
+ params.sourceQueryUrl,
7165
+ params.slicePreset,
7166
+ params.maxResultsPerSearch,
7167
+ params.pageSize,
7168
+ JSON.stringify(params.rawPayload),
7169
+ ]);
7170
+ const id = result.rows[0]?.id ?? "";
7171
+ if (!id) {
7172
+ throw new Error("Failed to create account search run in Neon: missing run id.");
7173
+ }
7174
+ return id;
7175
+ }
7176
+ const { data, error } = await params.store.client
7177
+ .from("linkedin_sales_nav_account_search_runs")
7178
+ .insert({
7179
+ org_id: params.orgId,
7180
+ source_query_url: params.sourceQueryUrl,
7181
+ slice_preset: params.slicePreset,
7182
+ max_results_per_search: params.maxResultsPerSearch,
7183
+ page_size: params.pageSize,
7184
+ status: "running",
7185
+ raw_payload: params.rawPayload,
7186
+ })
7187
+ .select("id")
7188
+ .single();
7189
+ if (error) {
7190
+ throw new Error(`Failed to create account search run in Neon: ${error.message}`);
7191
+ }
7192
+ const id = typeof data?.id === "string" ? data.id : "";
7193
+ if (!id) {
7194
+ throw new Error("Failed to create account search run in Neon: missing run id.");
7195
+ }
7196
+ return id;
7197
+ }
7198
+ async function updateSalesNavigatorAccountSearchRun(params) {
7199
+ if (params.store.kind === "postgres") {
7200
+ await params.store.client.query(`UPDATE public.linkedin_sales_nav_account_search_runs
7201
+ SET status = $2,
7202
+ total_slices = $3,
7203
+ stored_accounts = $4,
7204
+ attempted_slices = $5,
7205
+ split_events = $6,
7206
+ failed_slices = $7,
7207
+ unresolved_slices = $8,
7208
+ truncated = $9,
7209
+ finished_at = now(),
7210
+ last_error = $10,
7211
+ raw_payload = CASE
7212
+ WHEN $11::jsonb IS NULL THEN raw_payload
7213
+ ELSE COALESCE(raw_payload, '{}'::jsonb) || $11::jsonb
7214
+ END
7215
+ WHERE id = $1`, [
7216
+ params.runId,
7217
+ params.status,
7218
+ params.totalSlices,
7219
+ params.storedAccounts,
7220
+ params.attemptedSlices,
7221
+ params.splitEvents,
7222
+ params.failedSlices,
7223
+ params.unresolvedSlices,
7224
+ params.truncated,
7225
+ params.lastError ?? null,
7226
+ params.rawPayload ? JSON.stringify(params.rawPayload) : null,
7227
+ ]);
7228
+ return;
6342
7229
  }
6343
- if (payload.retryNotFoundAfterDays != null) {
6344
- url.searchParams.set('retryNotFoundAfterDays', String(payload.retryNotFoundAfterDays));
7230
+ let mergedRawPayload = null;
7231
+ if (params.rawPayload) {
7232
+ const { data: existing, error: selectError } = await params.store.client
7233
+ .from("linkedin_sales_nav_account_search_runs")
7234
+ .select("raw_payload")
7235
+ .eq("id", params.runId)
7236
+ .maybeSingle();
7237
+ if (selectError) {
7238
+ throw new Error(`Failed to read account search run payload in Neon: ${selectError.message}`);
7239
+ }
7240
+ const existingPayload = typeof existing?.raw_payload === "object" &&
7241
+ existing.raw_payload !== null &&
7242
+ !Array.isArray(existing.raw_payload)
7243
+ ? existing.raw_payload
7244
+ : {};
7245
+ mergedRawPayload = {
7246
+ ...existingPayload,
7247
+ ...params.rawPayload,
7248
+ };
6345
7249
  }
6346
- const { value } = await fetchCliJson(session, (currentSession) => fetch(url.toString(), {
6347
- method: "POST",
6348
- headers: {
6349
- Authorization: `Bearer ${currentSession.accessToken}`
7250
+ const { error } = await params.store.client
7251
+ .from("linkedin_sales_nav_account_search_runs")
7252
+ .update({
7253
+ status: params.status,
7254
+ total_slices: params.totalSlices,
7255
+ stored_accounts: params.storedAccounts,
7256
+ attempted_slices: params.attemptedSlices,
7257
+ split_events: params.splitEvents,
7258
+ failed_slices: params.failedSlices,
7259
+ unresolved_slices: params.unresolvedSlices,
7260
+ truncated: params.truncated,
7261
+ finished_at: new Date().toISOString(),
7262
+ last_error: params.lastError ?? null,
7263
+ ...(mergedRawPayload
7264
+ ? {
7265
+ raw_payload: mergedRawPayload,
7266
+ }
7267
+ : {}),
7268
+ })
7269
+ .eq("id", params.runId);
7270
+ if (error) {
7271
+ throw new Error(`Failed to update account search run in Neon: ${error.message}`);
7272
+ }
7273
+ }
7274
+ async function upsertSalesNavigatorAccountSearchSlice(params) {
7275
+ if (params.store.kind === "postgres") {
7276
+ const result = await params.store.client.query(`INSERT INTO public.linkedin_sales_nav_account_search_slices (
7277
+ run_id, org_id, sliced_query_url, applied_filters, split_trail, depth, status,
7278
+ total_results, fetched_pages, stored_accounts, retry_count, retry_delay_ms, last_error, raw_payload
7279
+ )
7280
+ VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb)
7281
+ ON CONFLICT (run_id, sliced_query_url)
7282
+ DO UPDATE SET
7283
+ applied_filters = excluded.applied_filters,
7284
+ split_trail = excluded.split_trail,
7285
+ depth = excluded.depth,
7286
+ status = excluded.status,
7287
+ total_results = excluded.total_results,
7288
+ fetched_pages = excluded.fetched_pages,
7289
+ stored_accounts = excluded.stored_accounts,
7290
+ retry_count = excluded.retry_count,
7291
+ retry_delay_ms = excluded.retry_delay_ms,
7292
+ last_error = excluded.last_error,
7293
+ raw_payload = excluded.raw_payload
7294
+ RETURNING id`, [
7295
+ params.runId,
7296
+ params.orgId,
7297
+ params.attempt.slicedQueryUrl,
7298
+ JSON.stringify(params.attempt.appliedFilters),
7299
+ JSON.stringify(params.attempt.splitTrail),
7300
+ params.attempt.depth,
7301
+ params.status,
7302
+ params.totalResults,
7303
+ params.fetchedPages,
7304
+ params.storedAccounts,
7305
+ params.retryCount,
7306
+ params.retryDelayMs,
7307
+ params.lastError ?? null,
7308
+ JSON.stringify(params.rawPayload),
7309
+ ]);
7310
+ const id = result.rows[0]?.id ?? "";
7311
+ if (!id) {
7312
+ throw new Error("Failed to upsert account search slice in Neon: missing slice id.");
7313
+ }
7314
+ return id;
7315
+ }
7316
+ const { data, error } = await params.store.client
7317
+ .from("linkedin_sales_nav_account_search_slices")
7318
+ .upsert({
7319
+ run_id: params.runId,
7320
+ org_id: params.orgId,
7321
+ sliced_query_url: params.attempt.slicedQueryUrl,
7322
+ applied_filters: params.attempt.appliedFilters,
7323
+ split_trail: params.attempt.splitTrail,
7324
+ depth: params.attempt.depth,
7325
+ status: params.status,
7326
+ total_results: params.totalResults,
7327
+ fetched_pages: params.fetchedPages,
7328
+ stored_accounts: params.storedAccounts,
7329
+ retry_count: params.retryCount,
7330
+ retry_delay_ms: params.retryDelayMs,
7331
+ last_error: params.lastError ?? null,
7332
+ raw_payload: params.rawPayload,
7333
+ }, { onConflict: "run_id,sliced_query_url" })
7334
+ .select("id")
7335
+ .single();
7336
+ if (error) {
7337
+ throw new Error(`Failed to upsert account search slice in Neon: ${error.message}`);
7338
+ }
7339
+ const id = typeof data?.id === "string" ? data.id : "";
7340
+ if (!id) {
7341
+ throw new Error("Failed to upsert account search slice in Neon: missing slice id.");
7342
+ }
7343
+ return id;
7344
+ }
7345
+ function stripPostgresUnsafeStringBytes(value) {
7346
+ return value.replace(/\u0000/g, "");
7347
+ }
7348
+ function sanitizeForPostgresJson(value) {
7349
+ if (typeof value === "string") {
7350
+ return stripPostgresUnsafeStringBytes(value);
7351
+ }
7352
+ if (Array.isArray(value)) {
7353
+ return value.map((item) => sanitizeForPostgresJson(item));
7354
+ }
7355
+ if (typeof value === "object" && value !== null) {
7356
+ const sanitized = {};
7357
+ for (const [key, nested] of Object.entries(value)) {
7358
+ sanitized[stripPostgresUnsafeStringBytes(key)] =
7359
+ sanitizeForPostgresJson(nested);
6350
7360
  }
6351
- }), LeadPoolEmailFinderProcessResponseSchema);
7361
+ return sanitized;
7362
+ }
6352
7363
  return value;
6353
7364
  }
7365
+ function sanitizeOptionalPostgresText(value) {
7366
+ return typeof value === "string" ? stripPostgresUnsafeStringBytes(value) : null;
7367
+ }
7368
+ async function upsertSalesNavigatorAccounts(params) {
7369
+ if (params.accounts.length === 0) {
7370
+ return 0;
7371
+ }
7372
+ const rows = params.accounts.map((account) => ({
7373
+ org_id: params.orgId,
7374
+ last_run_id: params.runId,
7375
+ last_slice_id: params.sliceId,
7376
+ account_key: stripPostgresUnsafeStringBytes(account.accountKey),
7377
+ sales_nav_company_url: sanitizeOptionalPostgresText(account.salesNavCompanyUrl),
7378
+ linkedin_company_url: sanitizeOptionalPostgresText(account.linkedinCompanyUrl),
7379
+ company_id: sanitizeOptionalPostgresText(account.companyId),
7380
+ company_name: sanitizeOptionalPostgresText(account.companyName),
7381
+ industry: sanitizeOptionalPostgresText(account.industry),
7382
+ location: sanitizeOptionalPostgresText(account.location),
7383
+ headquarters: sanitizeOptionalPostgresText(account.headquarters),
7384
+ employee_count: typeof account.employeeCount === "number" ? account.employeeCount : null,
7385
+ follower_count: typeof account.followerCount === "number" ? account.followerCount : null,
7386
+ company_type: sanitizeOptionalPostgresText(account.companyType),
7387
+ description: sanitizeOptionalPostgresText(account.description),
7388
+ website: sanitizeOptionalPostgresText(account.website),
7389
+ search_query_url: sanitizeOptionalPostgresText(account.searchQueryUrl),
7390
+ scraped_at: typeof account.scrapedAt === "string"
7391
+ ? stripPostgresUnsafeStringBytes(account.scrapedAt)
7392
+ : new Date().toISOString(),
7393
+ raw_payload: sanitizeForPostgresJson(typeof account.rawLocalSalesNavigatorAccountResult === "object" &&
7394
+ account.rawLocalSalesNavigatorAccountResult !== null
7395
+ ? account.rawLocalSalesNavigatorAccountResult
7396
+ : account),
7397
+ }));
7398
+ if (params.store.kind === "postgres") {
7399
+ for (const row of rows) {
7400
+ await params.store.client.query(`INSERT INTO public.linkedin_sales_nav_accounts (
7401
+ org_id, last_run_id, last_slice_id, account_key, sales_nav_company_url,
7402
+ linkedin_company_url, company_id, company_name, industry, location, headquarters,
7403
+ employee_count, follower_count, company_type, description, website, search_query_url,
7404
+ scraped_at, raw_payload
7405
+ )
7406
+ VALUES (
7407
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16,
7408
+ $17, $18, $19::jsonb
7409
+ )
7410
+ ON CONFLICT (org_id, account_key)
7411
+ DO UPDATE SET
7412
+ last_run_id = excluded.last_run_id,
7413
+ last_slice_id = excluded.last_slice_id,
7414
+ sales_nav_company_url = excluded.sales_nav_company_url,
7415
+ linkedin_company_url = excluded.linkedin_company_url,
7416
+ company_id = excluded.company_id,
7417
+ company_name = excluded.company_name,
7418
+ industry = excluded.industry,
7419
+ location = excluded.location,
7420
+ headquarters = excluded.headquarters,
7421
+ employee_count = excluded.employee_count,
7422
+ follower_count = excluded.follower_count,
7423
+ company_type = excluded.company_type,
7424
+ description = excluded.description,
7425
+ website = excluded.website,
7426
+ search_query_url = excluded.search_query_url,
7427
+ scraped_at = excluded.scraped_at,
7428
+ raw_payload = excluded.raw_payload`, [
7429
+ row.org_id,
7430
+ row.last_run_id,
7431
+ row.last_slice_id,
7432
+ row.account_key,
7433
+ row.sales_nav_company_url,
7434
+ row.linkedin_company_url,
7435
+ row.company_id,
7436
+ row.company_name,
7437
+ row.industry,
7438
+ row.location,
7439
+ row.headquarters,
7440
+ row.employee_count,
7441
+ row.follower_count,
7442
+ row.company_type,
7443
+ row.description,
7444
+ row.website,
7445
+ row.search_query_url,
7446
+ row.scraped_at,
7447
+ JSON.stringify(row.raw_payload),
7448
+ ]);
7449
+ }
7450
+ return rows.length;
7451
+ }
7452
+ const { error } = await params.store.client
7453
+ .from("linkedin_sales_nav_accounts")
7454
+ .upsert(rows, { onConflict: "org_id,account_key" });
7455
+ if (error) {
7456
+ throw new Error(`Failed to upsert Sales Navigator accounts in Neon: ${error.message}`);
7457
+ }
7458
+ return rows.length;
7459
+ }
7460
+ function asJsonObject(value) {
7461
+ return typeof value === "object" && value !== null && !Array.isArray(value)
7462
+ ? value
7463
+ : {};
7464
+ }
7465
+ function asJsonArray(value) {
7466
+ return Array.isArray(value) ? value : [];
7467
+ }
7468
+ function asOptionalNumber(value) {
7469
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
7470
+ }
7471
+ function resolveAccountRepairStartOffset(slice) {
7472
+ const rawPayload = asJsonObject(slice.rawPayload);
7473
+ const stop = asJsonObject(rawPayload.stop ?? asJsonObject(rawPayload.pacing).stop);
7474
+ const nextStart = asOptionalNumber(stop.nextStart) ??
7475
+ asOptionalNumber(stop.stoppedAtStart);
7476
+ if (nextStart != null) {
7477
+ return Math.max(0, Math.trunc(nextStart));
7478
+ }
7479
+ return Math.max(0, Math.trunc(slice.storedAccounts));
7480
+ }
7481
+ function repairSliceToCrawlAttempt(slice) {
7482
+ return {
7483
+ sourceQueryUrl: slice.sourceQueryUrl,
7484
+ slicedQueryUrl: slice.slicedQueryUrl,
7485
+ appliedFilters: slice.appliedFilters,
7486
+ depth: slice.depth,
7487
+ retryCount: slice.retryCount,
7488
+ maxResultsPerSearch: slice.maxResultsPerSearch,
7489
+ numberOfProfiles: slice.pageSize,
7490
+ slicePreset: slice.slicePreset,
7491
+ splitTrail: slice.splitTrail,
7492
+ };
7493
+ }
7494
+ async function listSalesNavigatorAccountSearchRepairSlices(params) {
7495
+ if (params.store.kind !== "postgres") {
7496
+ throw new Error("Account run repair requires direct Postgres access. Set DATABASE_URL or SALESPROMPTER_DATABASE_URL.");
7497
+ }
7498
+ const result = await params.store.client.query(`SELECT
7499
+ slices.id::text,
7500
+ slices.org_id,
7501
+ slices.run_id::text,
7502
+ runs.source_query_url,
7503
+ slices.sliced_query_url,
7504
+ slices.applied_filters,
7505
+ slices.split_trail,
7506
+ slices.depth,
7507
+ slices.status,
7508
+ slices.total_results,
7509
+ slices.fetched_pages,
7510
+ slices.stored_accounts,
7511
+ slices.retry_count,
7512
+ slices.retry_delay_ms,
7513
+ slices.last_error,
7514
+ slices.raw_payload,
7515
+ runs.slice_preset,
7516
+ runs.max_results_per_search,
7517
+ runs.page_size
7518
+ FROM public.linkedin_sales_nav_account_search_slices AS slices
7519
+ INNER JOIN public.linkedin_sales_nav_account_search_runs AS runs
7520
+ ON runs.id = slices.run_id
7521
+ WHERE slices.run_id = $1
7522
+ AND slices.status IN ('failed', 'unresolved')
7523
+ ORDER BY
7524
+ CASE WHEN slices.stored_accounts > 0 THEN 0 ELSE 1 END,
7525
+ slices.depth ASC,
7526
+ slices.updated_at ASC
7527
+ LIMIT $2`, [params.runId, params.limit]);
7528
+ return result.rows.map((row) => ({
7529
+ id: String(row.id),
7530
+ orgId: String(row.org_id),
7531
+ runId: String(row.run_id),
7532
+ sourceQueryUrl: String(row.source_query_url ?? row.sliced_query_url),
7533
+ slicedQueryUrl: String(row.sliced_query_url),
7534
+ appliedFilters: asJsonArray(row.applied_filters),
7535
+ splitTrail: asJsonArray(row.split_trail),
7536
+ depth: Number(row.depth ?? 0),
7537
+ status: row.status === "unresolved" ? "unresolved" : "failed",
7538
+ totalResults: typeof row.total_results === "number" ? row.total_results : null,
7539
+ fetchedPages: Number(row.fetched_pages ?? 0),
7540
+ storedAccounts: Number(row.stored_accounts ?? 0),
7541
+ retryCount: Number(row.retry_count ?? 0),
7542
+ retryDelayMs: Number(row.retry_delay_ms ?? 0),
7543
+ lastError: typeof row.last_error === "string" ? row.last_error : null,
7544
+ rawPayload: asJsonObject(row.raw_payload),
7545
+ slicePreset: String(row.slice_preset ?? "account-search-split"),
7546
+ maxResultsPerSearch: Number(row.max_results_per_search ?? 1000),
7547
+ pageSize: Number(row.page_size ?? 25),
7548
+ }));
7549
+ }
7550
+ async function summarizeSalesNavigatorAccountSearchRunFromSlices(params) {
7551
+ if (params.store.kind !== "postgres") {
7552
+ throw new Error("Account run summary requires direct Postgres access. Set DATABASE_URL or SALESPROMPTER_DATABASE_URL.");
7553
+ }
7554
+ const result = await params.store.client.query(`SELECT
7555
+ count(*) FILTER (WHERE status <> 'split')::int AS total_slices,
7556
+ COALESCE(sum(stored_accounts) FILTER (WHERE status <> 'split'), 0)::int AS stored_accounts,
7557
+ count(*) FILTER (WHERE status <> 'split')::int AS attempted_slices,
7558
+ count(*) FILTER (WHERE status = 'split')::int AS split_events,
7559
+ count(*) FILTER (WHERE status = 'failed')::int AS failed_slices,
7560
+ count(*) FILTER (WHERE status = 'unresolved')::int AS unresolved_slices,
7561
+ (
7562
+ SELECT last_error
7563
+ FROM public.linkedin_sales_nav_account_search_slices
7564
+ WHERE run_id = $1
7565
+ AND status IN ('failed', 'unresolved')
7566
+ ORDER BY updated_at DESC
7567
+ LIMIT 1
7568
+ ) AS last_error
7569
+ FROM public.linkedin_sales_nav_account_search_slices
7570
+ WHERE run_id = $1`, [params.runId]);
7571
+ const row = result.rows[0] ?? {};
7572
+ const failedSlices = Number(row.failed_slices ?? 0);
7573
+ const unresolvedSlices = Number(row.unresolved_slices ?? 0);
7574
+ return {
7575
+ totalSlices: Number(row.total_slices ?? 0),
7576
+ storedAccounts: Number(row.stored_accounts ?? 0),
7577
+ attemptedSlices: Number(row.attempted_slices ?? 0),
7578
+ splitEvents: Number(row.split_events ?? 0),
7579
+ failedSlices,
7580
+ unresolvedSlices,
7581
+ truncated: failedSlices > 0 || unresolvedSlices > 0,
7582
+ lastError: typeof row.last_error === "string" ? row.last_error : null,
7583
+ };
7584
+ }
7585
+ async function resolveOptionalAppSyncSession() {
7586
+ const envToken = resolveNonInteractiveAuthToken(process.env);
7587
+ if (envToken) {
7588
+ return await loginWithToken(envToken, process.env.SALESPROMPTER_API_BASE_URL?.trim());
7589
+ }
7590
+ const session = await readAuthSession();
7591
+ if (!session) {
7592
+ return null;
7593
+ }
7594
+ if (session.expiresAt !== undefined &&
7595
+ Date.now() >= Date.parse(session.expiresAt)) {
7596
+ if (canPromptForInteractiveLogin()) {
7597
+ await ensureInteractiveAuthSession(session.apiBaseUrl);
7598
+ return await readAuthSession();
7599
+ }
7600
+ return null;
7601
+ }
7602
+ return session;
7603
+ }
6354
7604
  async function syncPhantombusterContainersViaApp(session, payload) {
6355
7605
  const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/phantombuster/containers/sync`, {
6356
7606
  method: "POST",
@@ -6514,16 +7764,6 @@ async function runSalesNavigatorExport(session, payload, traceId, logOptions = {
6514
7764
  }
6515
7765
  }
6516
7766
  async function startSalesNavigatorExport(session, payload, traceId) {
6517
- const exportSession = await resolveSalesNavigatorExportSessionOverride(payload.slicedQueryUrl, {
6518
- source: "cli_salesnav_export_start"
6519
- });
6520
- const sessionOverridePayload = exportSession
6521
- ? {
6522
- sessionCookie: exportSession.sessionCookie,
6523
- selectedSessionCookieSha256: exportSession.selectedSessionCookieSha256,
6524
- selectedSessionUserAgent: exportSession.selectedSessionUserAgent
6525
- }
6526
- : {};
6527
7767
  return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/export`, {
6528
7768
  method: "POST",
6529
7769
  signal: AbortSignal.timeout(SALES_NAVIGATOR_EXPORT_START_TIMEOUT_MS),
@@ -6534,8 +7774,7 @@ async function startSalesNavigatorExport(session, payload, traceId) {
6534
7774
  },
6535
7775
  body: JSON.stringify({
6536
7776
  ...payload,
6537
- appliedFilters: serializeSalesNavigatorFiltersForApi(payload.appliedFilters),
6538
- ...sessionOverridePayload
7777
+ appliedFilters: serializeSalesNavigatorFiltersForApi(payload.appliedFilters)
6539
7778
  })
6540
7779
  }), SalesNavigatorExportStartResponseSchema);
6541
7780
  }
@@ -7057,6 +8296,73 @@ async function getSalesNavigatorCrawlStatus(session, jobId, traceId) {
7057
8296
  }
7058
8297
  }), SalesNavigatorCrawlStatusResponseSchema);
7059
8298
  }
8299
+ async function getSalesNavigatorAccountRunStatus(session, runId, options = {}, traceId) {
8300
+ const params = new URLSearchParams();
8301
+ if (options.cooldownSeconds != null) {
8302
+ params.set("cooldownSeconds", String(options.cooldownSeconds));
8303
+ }
8304
+ const query = params.toString();
8305
+ return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/accounts/runs/${runId}${query ? `?${query}` : ""}`, {
8306
+ method: "GET",
8307
+ headers: {
8308
+ Authorization: `Bearer ${currentSession.accessToken}`,
8309
+ ...buildTraceHeaders(traceId)
8310
+ }
8311
+ }), SalesNavigatorAccountRunStatusResponseSchema);
8312
+ }
8313
+ async function startSalesNavigatorAccountRunWorkflow(session, runId, options = {}, traceId) {
8314
+ return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/accounts/runs/${runId}/workflow`, {
8315
+ method: "POST",
8316
+ headers: {
8317
+ "Content-Type": "application/json",
8318
+ Authorization: `Bearer ${currentSession.accessToken}`,
8319
+ ...buildTraceHeaders(traceId)
8320
+ },
8321
+ body: JSON.stringify({
8322
+ runId,
8323
+ ...(options.cooldownSeconds != null
8324
+ ? { cooldownSeconds: options.cooldownSeconds }
8325
+ : {})
8326
+ })
8327
+ }), SalesNavigatorAccountWorkflowStartResponseSchema);
8328
+ }
8329
+ function buildSalesNavigatorAccountRepairCommand(params) {
8330
+ const parts = ["salesprompter", "accounts:repair", "--run-id", params.runId];
8331
+ if (params.curlFile) {
8332
+ parts.push("--curl-file", params.curlFile);
8333
+ }
8334
+ else {
8335
+ parts.push("--curl-file", "<account-search-curl-file>");
8336
+ }
8337
+ return parts.join(" ");
8338
+ }
8339
+ async function tryStartSalesNavigatorAccountWorkflow(params) {
8340
+ const session = await resolveOptionalAppSyncSession();
8341
+ if (!session) {
8342
+ return {
8343
+ started: false,
8344
+ workflowRunId: null,
8345
+ error: "No saved Salesprompter app auth session is available.",
8346
+ };
8347
+ }
8348
+ try {
8349
+ const workflow = await startSalesNavigatorAccountRunWorkflow(session, params.runId, {
8350
+ cooldownSeconds: params.cooldownSeconds,
8351
+ });
8352
+ return {
8353
+ started: true,
8354
+ workflowRunId: workflow.value.workflowRunId,
8355
+ error: null,
8356
+ };
8357
+ }
8358
+ catch (error) {
8359
+ return {
8360
+ started: false,
8361
+ workflowRunId: null,
8362
+ error: error instanceof Error ? error.message : String(error),
8363
+ };
8364
+ }
8365
+ }
7060
8366
  async function claimNextSalesNavigatorCrawlSlice(session, jobId, traceId) {
7061
8367
  return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/crawls/${jobId}/claim-next`, {
7062
8368
  method: "POST",
@@ -7342,95 +8648,25 @@ function buildSalesNavigatorSliceFailureReport(slice, error, options) {
7342
8648
  function formatSalesNavigatorSplitTrail(splitTrail) {
7343
8649
  return splitTrail.map((entry) => `${entry.key}:${entry.value.text}`);
7344
8650
  }
7345
- let cachedSalesNavigatorExportSessionOverride = null;
7346
- function hashSalesNavigatorSessionCookieForPhantombuster(sessionCookie) {
7347
- const liAt = extractLiAtCookieValue(sessionCookie);
7348
- if (!liAt) {
7349
- return null;
7350
- }
7351
- return createHash("sha256").update(liAt).digest("hex");
7352
- }
7353
- function cacheSalesNavigatorExportSessionOverride(queryUrl, value) {
7354
- cachedSalesNavigatorExportSessionOverride = {
7355
- queryUrl,
7356
- expiresAt: Date.now() + 120_000,
7357
- value
7358
- };
7359
- return value;
7360
- }
7361
- async function resolveSalesNavigatorExportSessionOverride(queryUrl, options) {
7362
- if (cachedSalesNavigatorExportSessionOverride &&
7363
- cachedSalesNavigatorExportSessionOverride.queryUrl === queryUrl &&
7364
- cachedSalesNavigatorExportSessionOverride.expiresAt > Date.now()) {
7365
- return cachedSalesNavigatorExportSessionOverride.value;
7366
- }
7367
- const localExtensionConfig = await readLocalLinkedInExtensionDirectLookupConfig();
7368
- if (localExtensionConfig?.cookie) {
7369
- const selectedSessionCookieSha256 = hashSalesNavigatorSessionCookieForPhantombuster(localExtensionConfig.cookie);
7370
- if (process.env.SALESPROMPTER_SALESNAV_EXPORT_SKIP_EXTENSION_PREFLIGHT === "1") {
7371
- return cacheSalesNavigatorExportSessionOverride(queryUrl, {
7372
- sessionCookie: localExtensionConfig.cookie,
7373
- selectedSessionCookieSha256,
7374
- selectedSessionUserAgent: localExtensionConfig.userAgent,
7375
- source: "chrome_extension"
7376
- });
7377
- }
7378
- const probe = await probeSalesNavigatorSearchSession(localExtensionConfig.cookie, queryUrl, {
7379
- timeoutMs: 8000
7380
- });
7381
- await options.logger?.log("salesnav.export.session.chrome_extension.preflight", {
7382
- source: options.source,
7383
- queryUrl,
7384
- status: probe.status,
7385
- selectedSessionCookieSha256,
7386
- finalUrl: probe.finalUrl,
7387
- validationError: probe.validationError
7388
- });
7389
- if (probe.status === "ok") {
7390
- return cacheSalesNavigatorExportSessionOverride(queryUrl, {
7391
- sessionCookie: localExtensionConfig.cookie,
7392
- selectedSessionCookieSha256,
7393
- selectedSessionUserAgent: localExtensionConfig.userAgent,
7394
- source: "chrome_extension"
7395
- });
7396
- }
7397
- }
7398
- const claimed = await claimValidatedSalesNavigatorSessionCookieForCli({
7399
- queryUrl,
7400
- source: options.source,
7401
- env: process.env
7402
- });
7403
- if (claimed?.sessionCookie) {
7404
- return cacheSalesNavigatorExportSessionOverride(queryUrl, {
7405
- sessionCookie: claimed.sessionCookie,
7406
- selectedSessionCookieSha256: claimed.sessionCookieSha256 ??
7407
- hashSalesNavigatorSessionCookieForPhantombuster(claimed.sessionCookie),
7408
- selectedSessionUserAgent: null,
7409
- source: "session_vault"
7410
- });
7411
- }
7412
- if (localExtensionConfig?.cookie &&
7413
- process.env.SALESPROMPTER_SALESNAV_EXPORT_REQUIRE_FRESH_SESSION === "1") {
7414
- throw new Error("The local Salesprompter Chrome extension session cookie is not valid for Sales Navigator. Reconnect the extension, then retry the CLI command.");
7415
- }
7416
- if (process.env.SALESPROMPTER_CLI_MANAGE_LINKEDIN_SESSIONS === "1") {
7417
- throw new Error("No validated LinkedIn Sales Navigator session cookie is available from the CLI-managed session pool.");
7418
- }
7419
- return null;
7420
- }
7421
8651
  async function ensureSalesNavigatorSessionPoolReady(queryUrl, options) {
7422
8652
  try {
7423
8653
  await options.logger?.log("salesnav.session_pool.preflight.started", {
7424
8654
  source: options.source,
7425
8655
  queryUrl
7426
8656
  });
7427
- const claimed = await resolveSalesNavigatorExportSessionOverride(queryUrl, options);
8657
+ const claimed = await claimValidatedSalesNavigatorSessionCookieForCli({
8658
+ queryUrl,
8659
+ source: options.source,
8660
+ env: process.env
8661
+ });
7428
8662
  await options.logger?.log("salesnav.session_pool.preflight.completed", {
7429
8663
  source: options.source,
7430
8664
  queryUrl,
7431
8665
  status: claimed ? "ok" : "skipped",
7432
- selectedSessionSource: claimed?.source ?? "app_managed",
7433
- selectedSessionCookieSha256: claimed?.selectedSessionCookieSha256 ?? null
8666
+ selectedSessionUserEmail: claimed?.userEmail ?? null,
8667
+ selectedSessionUserHandle: claimed?.userHandle ?? null,
8668
+ selectedSessionCookieSha256: claimed?.sessionCookieSha256 ?? null,
8669
+ selectedSessionLastIngestedSource: claimed?.lastIngestedSource ?? null
7434
8670
  });
7435
8671
  return {
7436
8672
  ready: true
@@ -9357,6 +10593,12 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
9357
10593
  commandName === "llm:ready" ||
9358
10594
  commandName === "contacts:find-linkedin-urls" ||
9359
10595
  commandName === "companies:find-linkedin-urls" ||
10596
+ commandName === "salesnav:accounts:split" ||
10597
+ commandName === "accounts:split" ||
10598
+ commandName === "salesnav:accounts:local-import" ||
10599
+ commandName === "accounts:local-import" ||
10600
+ commandName === "salesnav:accounts:repair" ||
10601
+ commandName === "accounts:repair" ||
9360
10602
  commandName.startsWith("packs:") ||
9361
10603
  ((commandName === "list" || commandName === "add") && parentCommandName === "packs")) {
9362
10604
  return;
@@ -9784,7 +11026,7 @@ program
9784
11026
  const titleLimit = options.titleLimit === undefined
9785
11027
  ? undefined
9786
11028
  : z.coerce.number().int().min(1).max(1000).parse(options.titleLimit);
9787
- const maxResultsPerSearch = z.coerce.number().int().min(1).max(100000).parse(options.maxResultsPerSearch);
11029
+ const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
9788
11030
  const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
9789
11031
  const maxSplitDepth = z.coerce.number().int().min(1).max(6).parse(options.maxSplitDepth);
9790
11032
  const maxSlicesPerTitle = z.coerce.number().int().min(1).max(10000).parse(options.maxSlicesPerTitle);
@@ -10329,62 +11571,1061 @@ program
10329
11571
  printOutput(payload);
10330
11572
  });
10331
11573
  program
10332
- .command("salesnav:crawl")
10333
- .alias("search:run")
10334
- .description("Run a saved people search, split broad result sets when needed, and store the finished output.")
10335
- .option("--query-url <url>", "Base LinkedIn Sales Navigator people search URL")
10336
- .option("--job-id <id>", "Resume an existing crawl job by id")
10337
- .option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
10338
- .option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
10339
- .option("--slice-preset <name>", "Slice preset label stored with the export runs", "human-resources-crawl")
10340
- .option("--client-id <number>", "Client id used to generate and store the legacy Neon lead list projection")
10341
- .option("--max-split-depth <number>", "Maximum number of adaptive split dimensions to use", "6")
10342
- .option("--max-slices <number>", "Safety cap for total claimed slices in this invocation", "1000")
10343
- .option("--max-retries <number>", "Retries for non-splitting export failures", "3")
10344
- .option("--probe-profiles <number>", "Profiles to scrape while probing whether a slice is still too broad", "100")
10345
- .option("--agent-busy-wait-seconds <number>", "Seconds to wait before retrying when the export agent is already busy", "30")
10346
- .option("--agent-busy-max-waits <number>", "How many busy-agent waits to tolerate before failing the slice", "20")
10347
- .option("--idle-poll-seconds <number>", "Seconds to wait before polling durable crawl status when remote slices are still running", "10")
10348
- .option("--idle-max-polls <number>", "How many no-claim status polls to tolerate before the crawl is considered stalled", "180")
10349
- .option("--parallel-exports <number>", "How many Sales Navigator slices to export concurrently in this invocation", "3")
10350
- .option("--allow-partial-success", "Exit 0 even when the durable crawl finishes with failures or remains non-terminal", false)
11574
+ .command("salesnav:accounts:split")
11575
+ .alias("accounts:split")
11576
+ .description("Split a Sales Navigator account search into account slices below the result cap.")
11577
+ .requiredOption("--query-url <url>", "LinkedIn Sales Navigator account search URL")
11578
+ .option("--curl-file <path>", "Path to a saved account-search curl request used as the local request template")
11579
+ .option("--org-id <id>", "Salesprompter workspace org id for Neon storage. Defaults to SALESPROMPTER_ORG_ID or saved auth session org.")
11580
+ .option("--max-results-per-search <number>", "Maximum account results allowed for each final slice", "1000")
11581
+ .option("--max-windowed-final-results <number>", "Bounded fallback fetch for an oversized final account slice when no split dimensions remain", "1000")
11582
+ .option("--no-windowed-final-slices", "Do not fetch pageable rows for oversized final account slices after adaptive splitting is exhausted")
11583
+ .option("--max-split-depth <number>", "Maximum number of adaptive account split dimensions to use", "3")
11584
+ .option("--max-slices <number>", "Safety cap for total account slices probed in this invocation", "1000")
11585
+ .option("--probe-count <number>", "Accounts to request while probing each slice count", "1")
11586
+ .option("--number-of-accounts <number>", "Maximum accounts to fetch per final slice, or 'all' for the full reported slice", "all")
11587
+ .option("--page-size <number>", "Accounts requested per Sales Navigator account-search API page", "25")
11588
+ .option("--page-delay-min-ms <number>", "Minimum randomized delay between account-search result pages", "1500")
11589
+ .option("--page-delay-max-ms <number>", "Maximum randomized delay between account-search result pages", "6000")
11590
+ .option("--max-retries <number>", "Bounded retries for transient LinkedIn probe failures", "1")
11591
+ .option("--retry-base-delay-ms <number>", "Base delay for full-jitter retry backoff", "1500")
11592
+ .option("--retry-max-delay-ms <number>", "Maximum delay for full-jitter retry backoff", "10000")
10351
11593
  .option("--out <path>", "Optional local JSON output path")
10352
- .option("--log-path <path>", "Optional JSONL log path with timestamps, trace id, and Sales Navigator slice metadata")
10353
- .option("--dry-run", "Preview the adaptive crawl plan without exporting anything", false)
11594
+ .option("--dry-run", "Preview static account split candidates without calling LinkedIn", false)
11595
+ .option("--plan-only", "Probe account-search counts and print adaptive final batches without storing rows", false)
11596
+ .option("--no-start-workflow", "Do not start the durable app workflow when the account run needs cooldown/repair")
11597
+ .option("--workflow-cooldown-seconds <number>", "Seconds the app workflow should wait after a rate-limit before asking the CLI to repair", "3600")
10354
11598
  .action(async (options) => {
10355
- const queryUrl = z.string().url().optional().parse(options.queryUrl);
10356
- const jobId = z.string().uuid().optional().parse(options.jobId);
10357
- const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
10358
- const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
10359
- const clientId = parseOptionalSalesNavigatorClientId(options.clientId);
10360
- const maxSplitDepth = z.coerce.number().int().min(1).max(6).parse(options.maxSplitDepth);
10361
- const maxSlices = z.coerce.number().int().min(1).max(10000).parse(options.maxSlices);
10362
- const maxRetries = z.coerce.number().int().min(0).max(5).parse(options.maxRetries);
10363
- const probeProfiles = z.coerce.number().int().min(1).max(2500).parse(options.probeProfiles);
10364
- const agentBusyWaitSeconds = z.coerce.number().int().min(1).max(300).parse(options.agentBusyWaitSeconds);
10365
- const agentBusyMaxWaits = z.coerce.number().int().min(0).max(120).parse(options.agentBusyMaxWaits);
10366
- const idlePollSeconds = z.coerce.number().int().min(0).max(300).parse(options.idlePollSeconds);
10367
- const idleMaxPolls = z.coerce.number().int().min(0).max(10000).parse(options.idleMaxPolls);
10368
- const parallelExports = z.coerce.number().int().min(1).max(10).parse(options.parallelExports);
10369
- const phantomLaneLimit = resolveSalesNavigatorPhantomLaneLimit(process.env);
10370
- const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
10371
- const logger = await createWorkflowLogger({
10372
- logPath: options.logPath ?? buildSalesNavigatorCrawlLogPath(jobId ?? queryUrl ?? "salesnav-crawl")
10373
- });
10374
- await logger.log("salesnav.crawl.command.started", {
10375
- queryUrl: queryUrl ?? null,
10376
- jobId: jobId ?? null,
10377
- maxResultsPerSearch,
10378
- numberOfProfiles,
10379
- clientId,
10380
- slicePreset: options.slicePreset,
10381
- maxSplitDepth,
10382
- maxSlices,
10383
- maxRetries,
10384
- probeProfiles,
10385
- agentBusyWaitSeconds,
10386
- agentBusyMaxWaits,
10387
- idlePollSeconds,
11599
+ const queryUrl = z.string().url().parse(options.queryUrl);
11600
+ const maxResultsPerSearch = z.coerce
11601
+ .number()
11602
+ .int()
11603
+ .min(1)
11604
+ .max(1000)
11605
+ .parse(options.maxResultsPerSearch);
11606
+ const maxWindowedFinalResults = z.coerce
11607
+ .number()
11608
+ .int()
11609
+ .min(maxResultsPerSearch)
11610
+ .max(1000)
11611
+ .parse(options.maxWindowedFinalResults);
11612
+ const maxSplitDepth = z.coerce
11613
+ .number()
11614
+ .int()
11615
+ .min(1)
11616
+ .max(DEFAULT_SALES_NAVIGATOR_ACCOUNT_CRAWL_DIMENSIONS.length)
11617
+ .parse(options.maxSplitDepth);
11618
+ const maxSlices = z.coerce
11619
+ .number()
11620
+ .int()
11621
+ .min(1)
11622
+ .max(10000)
11623
+ .parse(options.maxSlices);
11624
+ const workflowCooldownSeconds = z.coerce
11625
+ .number()
11626
+ .int()
11627
+ .min(60)
11628
+ .max(86_400)
11629
+ .parse(options.workflowCooldownSeconds);
11630
+ const probeCount = z.coerce
11631
+ .number()
11632
+ .int()
11633
+ .min(1)
11634
+ .max(100)
11635
+ .parse(options.probeCount);
11636
+ const numberOfAccountsOption = String(options.numberOfAccounts ?? "all")
11637
+ .trim()
11638
+ .toLowerCase();
11639
+ const requestedAccounts = numberOfAccountsOption === "all"
11640
+ ? null
11641
+ : z.coerce
11642
+ .number()
11643
+ .int()
11644
+ .min(1)
11645
+ .max(100000)
11646
+ .parse(options.numberOfAccounts);
11647
+ const pageSize = z.coerce
11648
+ .number()
11649
+ .int()
11650
+ .min(1)
11651
+ .max(100)
11652
+ .parse(options.pageSize);
11653
+ const pageDelayMinMs = z.coerce
11654
+ .number()
11655
+ .int()
11656
+ .min(0)
11657
+ .max(300000)
11658
+ .parse(options.pageDelayMinMs);
11659
+ const pageDelayMaxMs = z.coerce
11660
+ .number()
11661
+ .int()
11662
+ .min(0)
11663
+ .max(300000)
11664
+ .parse(options.pageDelayMaxMs);
11665
+ if (pageDelayMaxMs < pageDelayMinMs) {
11666
+ throw new Error("--page-delay-max-ms must be greater than or equal to --page-delay-min-ms.");
11667
+ }
11668
+ const retry = {
11669
+ maxRetries: z.coerce
11670
+ .number()
11671
+ .int()
11672
+ .min(0)
11673
+ .max(5)
11674
+ .parse(options.maxRetries),
11675
+ retryBaseDelayMs: z.coerce
11676
+ .number()
11677
+ .int()
11678
+ .min(0)
11679
+ .max(300000)
11680
+ .parse(options.retryBaseDelayMs),
11681
+ retryMaxDelayMs: z.coerce
11682
+ .number()
11683
+ .int()
11684
+ .min(0)
11685
+ .max(300000)
11686
+ .parse(options.retryMaxDelayMs),
11687
+ };
11688
+ const curlFile = typeof options.curlFile === "string" && options.curlFile.trim().length > 0
11689
+ ? options.curlFile.trim()
11690
+ : null;
11691
+ const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
11692
+ const preview = buildSalesNavigatorCrawlPreview({
11693
+ sourceQueryUrl: queryUrl,
11694
+ dimensions: DEFAULT_SALES_NAVIGATOR_ACCOUNT_CRAWL_DIMENSIONS,
11695
+ maxResultsPerSearch,
11696
+ numberOfProfiles: probeCount,
11697
+ slicePreset: "account-search-split",
11698
+ searchType: "account",
11699
+ });
11700
+ if (effectiveDryRun) {
11701
+ const payload = {
11702
+ status: "ok",
11703
+ dryRun: true,
11704
+ mode: "account-split-static-preview",
11705
+ finalBatchesAvailable: false,
11706
+ finalBatchesRequire: "Run with --plan-only or without --dry-run so the CLI can probe Sales Navigator result counts.",
11707
+ maxResultsPerSearch,
11708
+ maxWindowedFinalResults,
11709
+ windowedFinalSlices: options.windowedFinalSlices !== false,
11710
+ probeCount,
11711
+ requestedAccounts: requestedAccounts ?? "all",
11712
+ pageSize,
11713
+ sourceQueryUrl: queryUrl,
11714
+ rootQueryUrl: preview.root.slicedQueryUrl,
11715
+ rootAppliedFilters: preview.root.appliedFilters,
11716
+ dimensionOrder: preview.dimensions.map((dimension) => ({
11717
+ key: dimension.key,
11718
+ filterType: dimension.filterType,
11719
+ valueCount: dimension.values.length,
11720
+ })),
11721
+ firstSplitQueries: preview.firstSplit.map((attempt) => ({
11722
+ kind: "split-candidate",
11723
+ isFinalBatch: false,
11724
+ countStatus: "not_probed",
11725
+ warning: "This URL is a coarse split candidate, not a final batch. It may exceed maxResultsPerSearch until --plan-only probes counts and recursively splits it.",
11726
+ slicedQueryUrl: attempt.slicedQueryUrl,
11727
+ appliedFilters: attempt.appliedFilters,
11728
+ splitTrail: attempt.splitTrail.map((entry) => ({
11729
+ key: entry.key,
11730
+ filterType: entry.filterType,
11731
+ valueText: entry.value.text,
11732
+ })),
11733
+ })),
11734
+ };
11735
+ if (options.out) {
11736
+ await writeJsonFile(options.out, payload);
11737
+ }
11738
+ printOutput(payload);
11739
+ return;
11740
+ }
11741
+ const templateRequest = curlFile
11742
+ ? parseSalesNavigatorCurlRequest(await readFile(curlFile, "utf8"))
11743
+ : null;
11744
+ if (templateRequest &&
11745
+ !new URL(templateRequest.url).pathname.includes("/sales-api/salesApiAccountSearch")) {
11746
+ throw new Error("Account split --curl-file must contain a /sales-api/salesApiAccountSearch request.");
11747
+ }
11748
+ const config = templateRequest ? null : await readLinkedInDirectLookupConfig();
11749
+ const buildAccountApiRequest = (slicedQueryUrl) => templateRequest
11750
+ ? buildSalesNavigatorAccountApiRequestFromSearchUrlWithTemplate(slicedQueryUrl, templateRequest, pageSize)
11751
+ : buildSalesNavigatorAccountApiRequestFromSearchUrl(slicedQueryUrl, config, pageSize);
11752
+ const probeAccountSlice = async (attempt) => {
11753
+ const parsedRequest = buildAccountApiRequest(attempt.slicedQueryUrl);
11754
+ const probeResult = await fetchAllLocalSalesNavigatorAccounts(parsedRequest, {
11755
+ requestedAccounts: probeCount,
11756
+ pageSize,
11757
+ pageDelayMinMs,
11758
+ pageDelayMaxMs,
11759
+ maxAllowedResults: Number.MAX_SAFE_INTEGER,
11760
+ retry,
11761
+ });
11762
+ return { totalResults: probeResult.totalResults };
11763
+ };
11764
+ if (Boolean(options.planOnly)) {
11765
+ const crawl = await executeSalesNavigatorAdaptiveCrawl({
11766
+ sourceQueryUrl: queryUrl,
11767
+ dimensions: DEFAULT_SALES_NAVIGATOR_ACCOUNT_CRAWL_DIMENSIONS,
11768
+ maxResultsPerSearch,
11769
+ numberOfProfiles: probeCount,
11770
+ slicePreset: "account-search-split",
11771
+ searchType: "account",
11772
+ maxSplitDepth,
11773
+ maxSlices,
11774
+ maxRetries: 0,
11775
+ includeResidualExclusionSlices: true,
11776
+ adaptiveSplitProbing: true,
11777
+ probeSlice: probeAccountSlice,
11778
+ exportSlice: async (attempt) => {
11779
+ const probeResult = await probeAccountSlice(attempt);
11780
+ if (probeResult.totalResults == null ||
11781
+ probeResult.totalResults > attempt.maxResultsPerSearch) {
11782
+ throw new SalesNavigatorSliceTooBroadError(`Account slice has ${probeResult.totalResults ?? "unknown"} results, above ${attempt.maxResultsPerSearch}.`, { totalResults: probeResult.totalResults });
11783
+ }
11784
+ return probeResult;
11785
+ },
11786
+ });
11787
+ const payload = {
11788
+ status: "ok",
11789
+ dryRun: false,
11790
+ planOnly: true,
11791
+ mode: "account-split-plan",
11792
+ maxResultsPerSearch,
11793
+ probeCount,
11794
+ pageSize,
11795
+ sourceQueryUrl: queryUrl,
11796
+ finalSlices: crawl.exported.map(({ attempt, result }) => ({
11797
+ slicedQueryUrl: attempt.slicedQueryUrl,
11798
+ totalResults: result.totalResults,
11799
+ splitTrail: attempt.splitTrail.map((entry) => ({
11800
+ key: entry.key,
11801
+ filterType: entry.filterType,
11802
+ valueText: entry.value.text,
11803
+ })),
11804
+ })),
11805
+ splitEvents: crawl.splitEvents.map((event) => ({
11806
+ slicedQueryUrl: event.attempt.slicedQueryUrl,
11807
+ totalResults: event.totalResults,
11808
+ nextDimension: event.nextDimension,
11809
+ childCount: event.childCount,
11810
+ strategy: event.strategy ?? "fixed-order",
11811
+ probeSummary: event.probeSummary ?? [],
11812
+ })),
11813
+ unresolved: crawl.unresolved.map((failure) => ({
11814
+ slicedQueryUrl: failure.attempt.slicedQueryUrl,
11815
+ totalResults: failure.totalResults ?? null,
11816
+ splitTrail: failure.attempt.splitTrail.map((entry) => ({
11817
+ key: entry.key,
11818
+ filterType: entry.filterType,
11819
+ valueText: entry.value.text,
11820
+ })),
11821
+ error: failure.error,
11822
+ })),
11823
+ failed: crawl.failed.map((failure) => ({
11824
+ slicedQueryUrl: failure.attempt.slicedQueryUrl,
11825
+ totalResults: failure.totalResults ?? null,
11826
+ splitTrail: failure.attempt.splitTrail.map((entry) => ({
11827
+ key: entry.key,
11828
+ filterType: entry.filterType,
11829
+ valueText: entry.value.text,
11830
+ })),
11831
+ error: failure.error,
11832
+ })),
11833
+ attempted: crawl.attempted,
11834
+ truncated: crawl.truncated,
11835
+ remainingQueue: crawl.remainingQueue,
11836
+ };
11837
+ if (options.out) {
11838
+ await writeJsonFile(options.out, payload);
11839
+ }
11840
+ printOutput(payload);
11841
+ return;
11842
+ }
11843
+ const storedSession = await readAuthSession();
11844
+ const orgId = resolveAccountSearchStorageOrgId(storedSession, options.orgId);
11845
+ if (!orgId) {
11846
+ throw new Error("Account search storage requires --org-id, SALESPROMPTER_ORG_ID, or a saved Salesprompter workspace org. Run `salesprompter auth:login` once, or pass the workspace org id explicitly.");
11847
+ }
11848
+ const store = await createSalesNavigatorAccountSearchStore();
11849
+ const runId = await createSalesNavigatorAccountSearchRun({
11850
+ store,
11851
+ orgId,
11852
+ sourceQueryUrl: queryUrl,
11853
+ slicePreset: "account-search-split",
11854
+ maxResultsPerSearch,
11855
+ pageSize,
11856
+ rawPayload: {
11857
+ workflow: "salesnav:accounts:split",
11858
+ sourceQueryUrl: queryUrl,
11859
+ maxResultsPerSearch,
11860
+ maxWindowedFinalResults,
11861
+ windowedFinalSlices: options.windowedFinalSlices !== false,
11862
+ maxSplitDepth,
11863
+ maxSlices,
11864
+ probeCount,
11865
+ requestedAccounts: requestedAccounts ?? "all",
11866
+ pageSize,
11867
+ pageDelayMinMs,
11868
+ pageDelayMaxMs,
11869
+ workflowCooldownSeconds,
11870
+ },
11871
+ });
11872
+ let storedAccountCount = 0;
11873
+ let crawl;
11874
+ try {
11875
+ crawl = await executeSalesNavigatorAdaptiveCrawl({
11876
+ sourceQueryUrl: queryUrl,
11877
+ dimensions: DEFAULT_SALES_NAVIGATOR_ACCOUNT_CRAWL_DIMENSIONS,
11878
+ maxResultsPerSearch,
11879
+ numberOfProfiles: probeCount,
11880
+ slicePreset: "account-search-split",
11881
+ searchType: "account",
11882
+ maxSplitDepth,
11883
+ maxSlices,
11884
+ maxRetries: 0,
11885
+ includeResidualExclusionSlices: true,
11886
+ adaptiveSplitProbing: true,
11887
+ probeSlice: async (attempt) => {
11888
+ const parsedRequest = buildAccountApiRequest(attempt.slicedQueryUrl);
11889
+ const probeResult = await fetchAllLocalSalesNavigatorAccounts(parsedRequest, {
11890
+ requestedAccounts: probeCount,
11891
+ pageSize,
11892
+ pageDelayMinMs,
11893
+ pageDelayMaxMs,
11894
+ maxAllowedResults: Number.MAX_SAFE_INTEGER,
11895
+ retry,
11896
+ });
11897
+ return { totalResults: probeResult.totalResults };
11898
+ },
11899
+ exportSlice: async (attempt) => {
11900
+ const parsedRequest = buildAccountApiRequest(attempt.slicedQueryUrl);
11901
+ try {
11902
+ const fetchResult = await fetchAllLocalSalesNavigatorAccounts(parsedRequest, {
11903
+ requestedAccounts,
11904
+ pageSize,
11905
+ pageDelayMinMs,
11906
+ pageDelayMaxMs,
11907
+ maxAllowedResults: attempt.maxResultsPerSearch,
11908
+ retry,
11909
+ });
11910
+ const stop = fetchResult.pacing.stop;
11911
+ const partialError = describeLocalSalesNavigatorAccountPartialError(fetchResult);
11912
+ const sliceId = await upsertSalesNavigatorAccountSearchSlice({
11913
+ store,
11914
+ runId,
11915
+ orgId,
11916
+ attempt,
11917
+ status: partialError ? "failed" : "finished",
11918
+ totalResults: fetchResult.totalResults,
11919
+ fetchedPages: fetchResult.fetchedPages,
11920
+ storedAccounts: fetchResult.accounts.length,
11921
+ retryCount: fetchResult.pacing.retryCount,
11922
+ retryDelayMs: fetchResult.pacing.totalDelayMs,
11923
+ lastError: partialError,
11924
+ rawPayload: {
11925
+ sourceQueryUrl: attempt.sourceQueryUrl,
11926
+ slicedQueryUrl: attempt.slicedQueryUrl,
11927
+ pacing: fetchResult.pacing,
11928
+ stop,
11929
+ },
11930
+ });
11931
+ const storedAccounts = await upsertSalesNavigatorAccounts({
11932
+ store,
11933
+ orgId,
11934
+ runId,
11935
+ sliceId,
11936
+ accounts: fetchResult.accounts,
11937
+ });
11938
+ storedAccountCount += storedAccounts;
11939
+ if (fetchResult.pacing.rateLimit) {
11940
+ throw new SalesNavigatorCrawlStopError(fetchResult.pacing.rateLimit.message, {
11941
+ totalResults: fetchResult.totalResults,
11942
+ details: {
11943
+ stop,
11944
+ slicedQueryUrl: attempt.slicedQueryUrl,
11945
+ },
11946
+ });
11947
+ }
11948
+ return {
11949
+ estimatedTotalResults: fetchResult.totalResults,
11950
+ fetchedPages: fetchResult.fetchedPages,
11951
+ storedAccounts,
11952
+ retryCount: fetchResult.pacing.retryCount,
11953
+ retryDelayMs: fetchResult.pacing.totalDelayMs,
11954
+ stop,
11955
+ truncated: Boolean(partialError),
11956
+ error: partialError,
11957
+ };
11958
+ }
11959
+ catch (error) {
11960
+ if (error instanceof SalesNavigatorSliceTooBroadError) {
11961
+ throw error;
11962
+ }
11963
+ if (error instanceof SalesNavigatorCrawlStopError) {
11964
+ throw error;
11965
+ }
11966
+ await upsertSalesNavigatorAccountSearchSlice({
11967
+ store,
11968
+ runId,
11969
+ orgId,
11970
+ attempt,
11971
+ status: "failed",
11972
+ totalResults: null,
11973
+ fetchedPages: 0,
11974
+ storedAccounts: 0,
11975
+ retryCount: 0,
11976
+ retryDelayMs: 0,
11977
+ lastError: error instanceof Error ? error.message : String(error),
11978
+ rawPayload: {
11979
+ sourceQueryUrl: attempt.sourceQueryUrl,
11980
+ slicedQueryUrl: attempt.slicedQueryUrl,
11981
+ },
11982
+ });
11983
+ throw error;
11984
+ }
11985
+ },
11986
+ });
11987
+ }
11988
+ catch (error) {
11989
+ const errorMessage = error instanceof Error ? error.message : String(error);
11990
+ await updateSalesNavigatorAccountSearchRun({
11991
+ store,
11992
+ runId,
11993
+ status: "failed",
11994
+ totalSlices: 0,
11995
+ storedAccounts: storedAccountCount,
11996
+ attemptedSlices: 0,
11997
+ splitEvents: 0,
11998
+ failedSlices: 1,
11999
+ unresolvedSlices: 0,
12000
+ truncated: false,
12001
+ lastError: errorMessage,
12002
+ });
12003
+ await closeSalesNavigatorAccountSearchStore(store);
12004
+ if (Boolean(options.startWorkflow) &&
12005
+ /\b(429|rate[-\s]?limit|too many requests)\b/i.test(errorMessage)) {
12006
+ const workflow = await tryStartSalesNavigatorAccountWorkflow({
12007
+ runId,
12008
+ cooldownSeconds: workflowCooldownSeconds,
12009
+ });
12010
+ printOutput({
12011
+ status: "rate_limited",
12012
+ mode: "account-split",
12013
+ runId,
12014
+ error: errorMessage,
12015
+ workflow: {
12016
+ ...workflow,
12017
+ nextAction: "cooldown",
12018
+ cooldownSeconds: workflowCooldownSeconds,
12019
+ repairCommand: buildSalesNavigatorAccountRepairCommand({
12020
+ runId,
12021
+ curlFile,
12022
+ }),
12023
+ },
12024
+ });
12025
+ }
12026
+ throw error;
12027
+ }
12028
+ const windowedFinalSlices = [];
12029
+ const additionalFailed = [];
12030
+ const remainingUnresolved = [];
12031
+ for (const failure of crawl.unresolved) {
12032
+ const canFetchWindowedFinalSlice = options.windowedFinalSlices !== false &&
12033
+ failure.totalResults != null &&
12034
+ failure.totalResults <= maxWindowedFinalResults;
12035
+ if (canFetchWindowedFinalSlice) {
12036
+ try {
12037
+ const parsedRequest = buildAccountApiRequest(failure.attempt.slicedQueryUrl);
12038
+ const fetchResult = await fetchAllLocalSalesNavigatorAccounts(parsedRequest, {
12039
+ requestedAccounts,
12040
+ pageSize,
12041
+ pageDelayMinMs,
12042
+ pageDelayMaxMs,
12043
+ maxAllowedResults: maxWindowedFinalResults,
12044
+ retry,
12045
+ allowPartialPageErrors: true,
12046
+ });
12047
+ const windowedError = describeLocalSalesNavigatorAccountPartialError(fetchResult);
12048
+ const sliceId = await upsertSalesNavigatorAccountSearchSlice({
12049
+ store,
12050
+ runId,
12051
+ orgId,
12052
+ attempt: failure.attempt,
12053
+ status: windowedError ? "failed" : "finished",
12054
+ totalResults: fetchResult.totalResults,
12055
+ fetchedPages: fetchResult.fetchedPages,
12056
+ storedAccounts: fetchResult.accounts.length,
12057
+ retryCount: fetchResult.pacing.retryCount,
12058
+ retryDelayMs: fetchResult.pacing.totalDelayMs,
12059
+ lastError: windowedError,
12060
+ rawPayload: {
12061
+ sourceQueryUrl: failure.attempt.sourceQueryUrl,
12062
+ slicedQueryUrl: failure.attempt.slicedQueryUrl,
12063
+ oversizedFinalSlice: true,
12064
+ originalError: failure.error,
12065
+ pacing: fetchResult.pacing,
12066
+ stop: fetchResult.pacing.stop,
12067
+ },
12068
+ });
12069
+ const storedAccounts = await upsertSalesNavigatorAccounts({
12070
+ store,
12071
+ orgId,
12072
+ runId,
12073
+ sliceId,
12074
+ accounts: fetchResult.accounts,
12075
+ });
12076
+ storedAccountCount += storedAccounts;
12077
+ windowedFinalSlices.push({
12078
+ slicedQueryUrl: failure.attempt.slicedQueryUrl,
12079
+ appliedFilters: failure.attempt.appliedFilters,
12080
+ splitTrail: failure.attempt.splitTrail.map((entry) => ({
12081
+ key: entry.key,
12082
+ filterType: entry.filterType,
12083
+ valueText: entry.value.text,
12084
+ })),
12085
+ estimatedTotalResults: fetchResult.totalResults,
12086
+ fetchedPages: fetchResult.fetchedPages,
12087
+ storedAccounts,
12088
+ retryCount: fetchResult.pacing.retryCount,
12089
+ retryDelayMs: fetchResult.pacing.totalDelayMs,
12090
+ windowedFinal: true,
12091
+ truncated: Boolean(windowedError),
12092
+ error: windowedError,
12093
+ stop: fetchResult.pacing.stop,
12094
+ });
12095
+ continue;
12096
+ }
12097
+ catch (error) {
12098
+ additionalFailed.push({
12099
+ attempt: failure.attempt,
12100
+ totalResults: failure.totalResults ?? null,
12101
+ error: error instanceof Error ? error.message : String(error),
12102
+ });
12103
+ await upsertSalesNavigatorAccountSearchSlice({
12104
+ store,
12105
+ runId,
12106
+ orgId,
12107
+ attempt: failure.attempt,
12108
+ status: "failed",
12109
+ totalResults: failure.totalResults ?? null,
12110
+ fetchedPages: 0,
12111
+ storedAccounts: 0,
12112
+ retryCount: 0,
12113
+ retryDelayMs: 0,
12114
+ lastError: error instanceof Error ? error.message : String(error),
12115
+ rawPayload: {
12116
+ sourceQueryUrl: failure.attempt.sourceQueryUrl,
12117
+ slicedQueryUrl: failure.attempt.slicedQueryUrl,
12118
+ oversizedFinalSlice: true,
12119
+ originalError: failure.error,
12120
+ },
12121
+ });
12122
+ continue;
12123
+ }
12124
+ }
12125
+ remainingUnresolved.push(failure);
12126
+ }
12127
+ for (const failure of remainingUnresolved) {
12128
+ await upsertSalesNavigatorAccountSearchSlice({
12129
+ store,
12130
+ runId,
12131
+ orgId,
12132
+ attempt: failure.attempt,
12133
+ status: "unresolved",
12134
+ totalResults: failure.totalResults ?? null,
12135
+ fetchedPages: 0,
12136
+ storedAccounts: 0,
12137
+ retryCount: 0,
12138
+ retryDelayMs: 0,
12139
+ lastError: failure.error,
12140
+ rawPayload: {
12141
+ sourceQueryUrl: failure.attempt.sourceQueryUrl,
12142
+ slicedQueryUrl: failure.attempt.slicedQueryUrl,
12143
+ },
12144
+ });
12145
+ }
12146
+ for (const event of crawl.splitEvents) {
12147
+ await upsertSalesNavigatorAccountSearchSlice({
12148
+ store,
12149
+ runId,
12150
+ orgId,
12151
+ attempt: event.attempt,
12152
+ status: "split",
12153
+ totalResults: event.totalResults,
12154
+ fetchedPages: 1,
12155
+ storedAccounts: 0,
12156
+ retryCount: 0,
12157
+ retryDelayMs: 0,
12158
+ rawPayload: {
12159
+ sourceQueryUrl: event.attempt.sourceQueryUrl,
12160
+ slicedQueryUrl: event.attempt.slicedQueryUrl,
12161
+ nextDimension: event.nextDimension,
12162
+ childCount: event.childCount,
12163
+ },
12164
+ });
12165
+ }
12166
+ const finalSlices = crawl.exported.map(({ attempt, result }) => ({
12167
+ slicedQueryUrl: attempt.slicedQueryUrl,
12168
+ appliedFilters: attempt.appliedFilters,
12169
+ splitTrail: attempt.splitTrail.map((entry) => ({
12170
+ key: entry.key,
12171
+ filterType: entry.filterType,
12172
+ valueText: entry.value.text,
12173
+ })),
12174
+ estimatedTotalResults: result.estimatedTotalResults,
12175
+ fetchedPages: result.fetchedPages,
12176
+ storedAccounts: result.storedAccounts,
12177
+ retryCount: result.retryCount,
12178
+ retryDelayMs: result.retryDelayMs,
12179
+ stop: result.stop,
12180
+ truncated: result.truncated,
12181
+ error: result.error,
12182
+ })).concat(windowedFinalSlices);
12183
+ const failed = crawl.failed.concat(additionalFailed);
12184
+ const rootReportedTotal = crawl.splitEvents.find((event) => event.attempt.depth === 0)?.totalResults ?? null;
12185
+ const finalSliceReportedTotalSum = finalSlices.reduce((sum, slice) => sum + (slice.estimatedTotalResults ?? 0), 0);
12186
+ const missingReportedFromFinalSlices = rootReportedTotal == null
12187
+ ? null
12188
+ : Math.max(0, rootReportedTotal - finalSliceReportedTotalSum);
12189
+ const missingStoredFromRoot = rootReportedTotal == null
12190
+ ? null
12191
+ : Math.max(0, rootReportedTotal - storedAccountCount);
12192
+ const truncated = crawl.truncated ||
12193
+ finalSlices.some((slice) => slice.truncated) ||
12194
+ (missingReportedFromFinalSlices != null && missingReportedFromFinalSlices > 0) ||
12195
+ (missingStoredFromRoot != null && missingStoredFromRoot > 0);
12196
+ const coverageError = missingReportedFromFinalSlices != null && missingReportedFromFinalSlices > 0
12197
+ ? `Final account slices explain ${finalSliceReportedTotalSum} of ${rootReportedTotal} root reported results; missing reported total ${missingReportedFromFinalSlices}.`
12198
+ : missingStoredFromRoot != null && missingStoredFromRoot > 0
12199
+ ? `Stored ${storedAccountCount} of ${rootReportedTotal} root reported accounts; missing stored total ${missingStoredFromRoot}.`
12200
+ : null;
12201
+ const payload = {
12202
+ status: "ok",
12203
+ dryRun: false,
12204
+ mode: "account-split",
12205
+ maxResultsPerSearch,
12206
+ probeCount,
12207
+ requestedAccounts: requestedAccounts ?? "all",
12208
+ pageSize,
12209
+ sourceQueryUrl: queryUrl,
12210
+ runId,
12211
+ finalSlices,
12212
+ splitEvents: crawl.splitEvents.map((event) => ({
12213
+ slicedQueryUrl: event.attempt.slicedQueryUrl,
12214
+ totalResults: event.totalResults,
12215
+ nextDimension: event.nextDimension,
12216
+ childCount: event.childCount,
12217
+ strategy: event.strategy ?? "fixed-order",
12218
+ probeSummary: event.probeSummary ?? [],
12219
+ })),
12220
+ unresolved: remainingUnresolved.map((failure) => ({
12221
+ slicedQueryUrl: failure.attempt.slicedQueryUrl,
12222
+ splitTrail: failure.attempt.splitTrail.map((entry) => ({
12223
+ key: entry.key,
12224
+ filterType: entry.filterType,
12225
+ valueText: entry.value.text,
12226
+ })),
12227
+ totalResults: failure.totalResults ?? null,
12228
+ error: failure.error,
12229
+ })),
12230
+ failed: failed.map((failure) => ({
12231
+ slicedQueryUrl: failure.attempt.slicedQueryUrl,
12232
+ splitTrail: failure.attempt.splitTrail.map((entry) => ({
12233
+ key: entry.key,
12234
+ filterType: entry.filterType,
12235
+ valueText: entry.value.text,
12236
+ })),
12237
+ totalResults: failure.totalResults ?? null,
12238
+ error: failure.error,
12239
+ })),
12240
+ attempted: crawl.attempted,
12241
+ truncated,
12242
+ remainingQueue: crawl.remainingQueue,
12243
+ storedAccounts: storedAccountCount,
12244
+ reportedCoverage: {
12245
+ rootReportedTotal,
12246
+ finalSliceReportedTotalSum,
12247
+ missingReportedFromFinalSlices,
12248
+ storedAccounts: storedAccountCount,
12249
+ missingStoredFromRoot,
12250
+ },
12251
+ };
12252
+ await updateSalesNavigatorAccountSearchRun({
12253
+ store,
12254
+ runId,
12255
+ status: payload.unresolved.length > 0 || payload.failed.length > 0 || payload.truncated
12256
+ ? "failed"
12257
+ : "finished",
12258
+ totalSlices: payload.finalSlices.length,
12259
+ storedAccounts: storedAccountCount,
12260
+ attemptedSlices: crawl.attempted,
12261
+ splitEvents: crawl.splitEvents.length,
12262
+ failedSlices: payload.failed.length +
12263
+ finalSlices.filter((slice) => slice.error).length,
12264
+ unresolvedSlices: payload.unresolved.length,
12265
+ truncated: payload.truncated,
12266
+ lastError: payload.unresolved[0]?.error ??
12267
+ payload.failed[0]?.error ??
12268
+ windowedFinalSlices.find((slice) => slice.error)?.error ??
12269
+ finalSlices.find((slice) => slice.error)?.error ??
12270
+ coverageError ??
12271
+ (payload.truncated ? "Account search split truncated before completion." : null),
12272
+ rawPayload: {
12273
+ reportedCoverage: payload.reportedCoverage,
12274
+ },
12275
+ });
12276
+ const needsWorkflow = payload.unresolved.length > 0 || payload.failed.length > 0 || payload.truncated;
12277
+ const workflow = needsWorkflow
12278
+ ? Boolean(options.startWorkflow)
12279
+ ? await tryStartSalesNavigatorAccountWorkflow({
12280
+ runId,
12281
+ cooldownSeconds: workflowCooldownSeconds,
12282
+ })
12283
+ : {
12284
+ started: false,
12285
+ workflowRunId: null,
12286
+ error: "Workflow start disabled by --no-start-workflow.",
12287
+ }
12288
+ : {
12289
+ started: false,
12290
+ workflowRunId: null,
12291
+ error: null,
12292
+ };
12293
+ const outputPayload = {
12294
+ ...payload,
12295
+ workflow: {
12296
+ ...workflow,
12297
+ nextAction: needsWorkflow ? "cooldown" : "complete",
12298
+ cooldownSeconds: needsWorkflow ? workflowCooldownSeconds : null,
12299
+ repairCommand: needsWorkflow
12300
+ ? buildSalesNavigatorAccountRepairCommand({
12301
+ runId,
12302
+ curlFile,
12303
+ })
12304
+ : null,
12305
+ },
12306
+ };
12307
+ if (options.out) {
12308
+ await writeJsonFile(options.out, outputPayload);
12309
+ }
12310
+ printOutput(outputPayload);
12311
+ await closeSalesNavigatorAccountSearchStore(store);
12312
+ if (needsWorkflow) {
12313
+ throw new Error(`Account search split did not finish cleanly. finalSlices=${payload.finalSlices.length} unresolved=${payload.unresolved.length} failed=${payload.failed.length} truncated=${payload.truncated}`);
12314
+ }
12315
+ });
12316
+ program
12317
+ .command("salesnav:accounts:local-import")
12318
+ .alias("accounts:local-import")
12319
+ .description("Run a saved Sales Navigator account request locally and store the accounts in Neon.")
12320
+ .option("--curl-file <path>", "Path to a saved account-search curl request copied from the Sales Navigator network panel")
12321
+ .option("--query-url <url>", "Sales Navigator account search URL. Uses the locally captured extension session automatically.")
12322
+ .option("--org-id <id>", "Salesprompter workspace org id for Neon storage. Defaults to SALESPROMPTER_ORG_ID or saved auth session org.")
12323
+ .option("--max-results-per-search <number>", "Maximum expected account results for this local request", "1000")
12324
+ .option("--number-of-accounts <number>", "Total accounts to import, or 'all' for the full reported result set", "all")
12325
+ .option("--page-size <number>", "Accounts requested per local Sales Navigator API page", "25")
12326
+ .option("--start-offset <number>", "Account-search result offset to start from when resuming a local import", "0")
12327
+ .option("--page-delay-min-ms <number>", "Minimum randomized delay between account-search result pages", "20000")
12328
+ .option("--page-delay-max-ms <number>", "Maximum randomized delay between account-search result pages", "35000")
12329
+ .option("--max-retries <number>", "Bounded retries for transient network/5xx failures. Rate limits still stop immediately.", "2")
12330
+ .option("--retry-base-delay-ms <number>", "Base delay for full-jitter retry backoff", "2000")
12331
+ .option("--retry-max-delay-ms <number>", "Maximum delay for full-jitter retry backoff", "30000")
12332
+ .option("--slice-preset <name>", "Slice preset label stored with the import run", "local-account-search")
12333
+ .option("--allow-partial-page-errors", "Persist rows fetched before a later non-rate-limit page error", false)
12334
+ .option("--out <path>", "Optional local JSON output path")
12335
+ .option("--dry-run", "Run and normalize the local account request without writing to Neon", false)
12336
+ .action(async (options) => {
12337
+ const maxResultsPerSearch = z.coerce
12338
+ .number()
12339
+ .int()
12340
+ .min(1)
12341
+ .max(1000)
12342
+ .parse(options.maxResultsPerSearch);
12343
+ const numberOfAccountsOption = String(options.numberOfAccounts ?? "all")
12344
+ .trim()
12345
+ .toLowerCase();
12346
+ const requestedAccounts = numberOfAccountsOption === "all"
12347
+ ? null
12348
+ : z.coerce
12349
+ .number()
12350
+ .int()
12351
+ .min(1)
12352
+ .max(100000)
12353
+ .parse(options.numberOfAccounts);
12354
+ const pageSize = z.coerce
12355
+ .number()
12356
+ .int()
12357
+ .min(1)
12358
+ .max(100)
12359
+ .parse(options.pageSize);
12360
+ const startOffset = z.coerce
12361
+ .number()
12362
+ .int()
12363
+ .min(0)
12364
+ .max(100000)
12365
+ .parse(options.startOffset);
12366
+ const pageDelayMinMs = z.coerce
12367
+ .number()
12368
+ .int()
12369
+ .min(0)
12370
+ .max(300000)
12371
+ .parse(options.pageDelayMinMs);
12372
+ const pageDelayMaxMs = z.coerce
12373
+ .number()
12374
+ .int()
12375
+ .min(0)
12376
+ .max(300000)
12377
+ .parse(options.pageDelayMaxMs);
12378
+ if (pageDelayMaxMs < pageDelayMinMs) {
12379
+ throw new Error("--page-delay-max-ms must be greater than or equal to --page-delay-min-ms.");
12380
+ }
12381
+ const retry = {
12382
+ maxRetries: z.coerce
12383
+ .number()
12384
+ .int()
12385
+ .min(0)
12386
+ .max(5)
12387
+ .parse(options.maxRetries),
12388
+ retryBaseDelayMs: z.coerce
12389
+ .number()
12390
+ .int()
12391
+ .min(0)
12392
+ .max(300000)
12393
+ .parse(options.retryBaseDelayMs),
12394
+ retryMaxDelayMs: z.coerce
12395
+ .number()
12396
+ .int()
12397
+ .min(0)
12398
+ .max(300000)
12399
+ .parse(options.retryMaxDelayMs),
12400
+ };
12401
+ const curlFile = typeof options.curlFile === "string" && options.curlFile.trim().length > 0
12402
+ ? options.curlFile.trim()
12403
+ : null;
12404
+ const queryUrl = typeof options.queryUrl === "string" && options.queryUrl.trim().length > 0
12405
+ ? z.string().url().parse(options.queryUrl.trim())
12406
+ : null;
12407
+ if (Boolean(curlFile) === Boolean(queryUrl)) {
12408
+ throw new Error("Provide exactly one of --curl-file or --query-url.");
12409
+ }
12410
+ const parsedRequest = curlFile
12411
+ ? parseSalesNavigatorCurlRequest(await readFile(curlFile, "utf8"))
12412
+ : buildSalesNavigatorAccountApiRequestFromSearchUrl(queryUrl, await readLinkedInDirectLookupConfig(), pageSize);
12413
+ if (!new URL(parsedRequest.url).pathname.includes("/sales-api/salesApiAccountSearch")) {
12414
+ throw new Error("Account local import requires a /sales-api/salesApiAccountSearch request.");
12415
+ }
12416
+ const localFetch = await fetchAllLocalSalesNavigatorAccounts(parsedRequest, {
12417
+ requestedAccounts,
12418
+ startOffset,
12419
+ pageSize,
12420
+ pageDelayMinMs,
12421
+ pageDelayMaxMs,
12422
+ maxAllowedResults: maxResultsPerSearch,
12423
+ retry,
12424
+ allowPartialPageErrors: Boolean(options.allowPartialPageErrors),
12425
+ });
12426
+ const accounts = localFetch.accounts;
12427
+ const totalResults = localFetch.totalResults;
12428
+ const dryRun = Boolean(options.dryRun);
12429
+ const rateLimit = localFetch.pacing.rateLimit;
12430
+ const pageError = localFetch.pacing.pageError;
12431
+ const partialError = describeLocalSalesNavigatorAccountPartialError(localFetch);
12432
+ if (accounts.length === 0) {
12433
+ throw new Error("The local Sales Navigator account response did not contain any importable account rows.");
12434
+ }
12435
+ const sourceQueryUrl = queryUrl ?? localFetch.sourceQueryUrl;
12436
+ const basePayload = {
12437
+ status: "ok",
12438
+ dryRun,
12439
+ mode: "account-local-import",
12440
+ sourceQueryUrl,
12441
+ totalResults,
12442
+ discovered: accounts.length,
12443
+ fetchedPages: localFetch.fetchedPages,
12444
+ requestedAccounts: requestedAccounts ?? "all",
12445
+ maxResultsPerSearch,
12446
+ startOffset,
12447
+ pageSize,
12448
+ pacing: localFetch.pacing,
12449
+ rateLimit: rateLimit ?? null,
12450
+ pageError: pageError ?? null,
12451
+ stop: localFetch.pacing.stop,
12452
+ accounts,
12453
+ };
12454
+ if (dryRun) {
12455
+ if (options.out) {
12456
+ await writeJsonFile(options.out, basePayload);
12457
+ }
12458
+ printOutput(basePayload);
12459
+ return;
12460
+ }
12461
+ const storedSession = await readAuthSession();
12462
+ const orgId = resolveAccountSearchStorageOrgId(storedSession, options.orgId);
12463
+ if (!orgId) {
12464
+ throw new Error("Account local import storage requires --org-id, SALESPROMPTER_ORG_ID, or a saved Salesprompter workspace org. Run `salesprompter auth:login` once, or pass the workspace org id explicitly.");
12465
+ }
12466
+ const store = await createSalesNavigatorAccountSearchStore();
12467
+ const runId = await createSalesNavigatorAccountSearchRun({
12468
+ store,
12469
+ orgId,
12470
+ sourceQueryUrl,
12471
+ slicePreset: options.slicePreset,
12472
+ maxResultsPerSearch,
12473
+ pageSize,
12474
+ rawPayload: {
12475
+ workflow: "salesnav:accounts:local-import",
12476
+ sourceQueryUrl,
12477
+ requestUrl: localFetch.sourceQueryUrl,
12478
+ maxResultsPerSearch,
12479
+ requestedAccounts: requestedAccounts ?? "all",
12480
+ startOffset,
12481
+ pageSize,
12482
+ pageDelayMinMs,
12483
+ pageDelayMaxMs,
12484
+ rateLimit: rateLimit ?? null,
12485
+ pageError: pageError ?? null,
12486
+ stop: localFetch.pacing.stop,
12487
+ },
12488
+ });
12489
+ const attempt = {
12490
+ sourceQueryUrl,
12491
+ slicedQueryUrl: sourceQueryUrl,
12492
+ appliedFilters: [],
12493
+ depth: 0,
12494
+ retryCount: 0,
12495
+ maxResultsPerSearch,
12496
+ numberOfProfiles: requestedAccounts ?? accounts.length,
12497
+ slicePreset: options.slicePreset,
12498
+ splitTrail: [],
12499
+ };
12500
+ try {
12501
+ const sliceId = await upsertSalesNavigatorAccountSearchSlice({
12502
+ store,
12503
+ runId,
12504
+ orgId,
12505
+ attempt,
12506
+ status: partialError ? "failed" : "finished",
12507
+ totalResults,
12508
+ fetchedPages: localFetch.fetchedPages,
12509
+ storedAccounts: accounts.length,
12510
+ retryCount: localFetch.pacing.retryCount,
12511
+ retryDelayMs: localFetch.pacing.totalDelayMs,
12512
+ lastError: partialError,
12513
+ rawPayload: {
12514
+ sourceQueryUrl,
12515
+ requestUrl: localFetch.sourceQueryUrl,
12516
+ startOffset,
12517
+ pacing: localFetch.pacing,
12518
+ stop: localFetch.pacing.stop,
12519
+ },
12520
+ });
12521
+ const storedAccounts = await upsertSalesNavigatorAccounts({
12522
+ store,
12523
+ orgId,
12524
+ runId,
12525
+ sliceId,
12526
+ accounts,
12527
+ });
12528
+ await updateSalesNavigatorAccountSearchRun({
12529
+ store,
12530
+ runId,
12531
+ status: partialError ? "failed" : "finished",
12532
+ totalSlices: 1,
12533
+ storedAccounts,
12534
+ attemptedSlices: 1,
12535
+ splitEvents: 0,
12536
+ failedSlices: partialError ? 1 : 0,
12537
+ unresolvedSlices: 0,
12538
+ truncated: Boolean(partialError),
12539
+ lastError: partialError,
12540
+ });
12541
+ const payload = {
12542
+ ...basePayload,
12543
+ runId,
12544
+ sliceId,
12545
+ storedAccounts,
12546
+ };
12547
+ if (options.out) {
12548
+ await writeJsonFile(options.out, payload);
12549
+ }
12550
+ printOutput(payload);
12551
+ }
12552
+ catch (error) {
12553
+ await updateSalesNavigatorAccountSearchRun({
12554
+ store,
12555
+ runId,
12556
+ status: "failed",
12557
+ totalSlices: 0,
12558
+ storedAccounts: 0,
12559
+ attemptedSlices: 1,
12560
+ splitEvents: 0,
12561
+ failedSlices: 1,
12562
+ unresolvedSlices: 0,
12563
+ truncated: false,
12564
+ lastError: error instanceof Error ? error.message : String(error),
12565
+ });
12566
+ throw error;
12567
+ }
12568
+ finally {
12569
+ await closeSalesNavigatorAccountSearchStore(store);
12570
+ }
12571
+ });
12572
+ program
12573
+ .command("salesnav:crawl")
12574
+ .alias("search:run")
12575
+ .description("Run a saved people search, split broad result sets when needed, and store the finished output.")
12576
+ .option("--query-url <url>", "Base LinkedIn Sales Navigator people search URL")
12577
+ .option("--job-id <id>", "Resume an existing crawl job by id")
12578
+ .option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
12579
+ .option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
12580
+ .option("--slice-preset <name>", "Slice preset label stored with the export runs", "human-resources-crawl")
12581
+ .option("--client-id <number>", "Client id used to generate and store the legacy Neon lead list projection")
12582
+ .option("--max-split-depth <number>", "Maximum number of adaptive split dimensions to use", "6")
12583
+ .option("--max-slices <number>", "Safety cap for total claimed slices in this invocation", "1000")
12584
+ .option("--max-retries <number>", "Retries for non-splitting export failures", "3")
12585
+ .option("--probe-profiles <number>", "Profiles to scrape while probing whether a slice is still too broad", "100")
12586
+ .option("--agent-busy-wait-seconds <number>", "Seconds to wait before retrying when the export agent is already busy", "30")
12587
+ .option("--agent-busy-max-waits <number>", "How many busy-agent waits to tolerate before failing the slice", "20")
12588
+ .option("--idle-poll-seconds <number>", "Seconds to wait before polling durable crawl status when remote slices are still running", "10")
12589
+ .option("--idle-max-polls <number>", "How many no-claim status polls to tolerate before the crawl is considered stalled", "180")
12590
+ .option("--parallel-exports <number>", "How many Sales Navigator slices to export concurrently in this invocation", "3")
12591
+ .option("--allow-partial-success", "Exit 0 even when the durable crawl finishes with failures or remains non-terminal", false)
12592
+ .option("--out <path>", "Optional local JSON output path")
12593
+ .option("--log-path <path>", "Optional JSONL log path with timestamps, trace id, and Sales Navigator slice metadata")
12594
+ .option("--dry-run", "Preview the adaptive crawl plan without exporting anything", false)
12595
+ .action(async (options) => {
12596
+ const queryUrl = z.string().url().optional().parse(options.queryUrl);
12597
+ const jobId = z.string().uuid().optional().parse(options.jobId);
12598
+ const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
12599
+ const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
12600
+ const clientId = parseOptionalSalesNavigatorClientId(options.clientId);
12601
+ const maxSplitDepth = z.coerce.number().int().min(1).max(6).parse(options.maxSplitDepth);
12602
+ const maxSlices = z.coerce.number().int().min(1).max(10000).parse(options.maxSlices);
12603
+ const maxRetries = z.coerce.number().int().min(0).max(5).parse(options.maxRetries);
12604
+ const probeProfiles = z.coerce.number().int().min(1).max(2500).parse(options.probeProfiles);
12605
+ const agentBusyWaitSeconds = z.coerce.number().int().min(1).max(300).parse(options.agentBusyWaitSeconds);
12606
+ const agentBusyMaxWaits = z.coerce.number().int().min(0).max(120).parse(options.agentBusyMaxWaits);
12607
+ const idlePollSeconds = z.coerce.number().int().min(0).max(300).parse(options.idlePollSeconds);
12608
+ const idleMaxPolls = z.coerce.number().int().min(0).max(10000).parse(options.idleMaxPolls);
12609
+ const parallelExports = z.coerce.number().int().min(1).max(10).parse(options.parallelExports);
12610
+ const phantomLaneLimit = resolveSalesNavigatorPhantomLaneLimit(process.env);
12611
+ const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
12612
+ const logger = await createWorkflowLogger({
12613
+ logPath: options.logPath ?? buildSalesNavigatorCrawlLogPath(jobId ?? queryUrl ?? "salesnav-crawl")
12614
+ });
12615
+ await logger.log("salesnav.crawl.command.started", {
12616
+ queryUrl: queryUrl ?? null,
12617
+ jobId: jobId ?? null,
12618
+ maxResultsPerSearch,
12619
+ numberOfProfiles,
12620
+ clientId,
12621
+ slicePreset: options.slicePreset,
12622
+ maxSplitDepth,
12623
+ maxSlices,
12624
+ maxRetries,
12625
+ probeProfiles,
12626
+ agentBusyWaitSeconds,
12627
+ agentBusyMaxWaits,
12628
+ idlePollSeconds,
10388
12629
  idleMaxPolls,
10389
12630
  parallelExports,
10390
12631
  phantomLaneLimit,
@@ -10620,22 +12861,362 @@ program
10620
12861
  recentEvents
10621
12862
  });
10622
12863
  });
12864
+ program
12865
+ .command("salesnav:accounts:repair")
12866
+ .alias("accounts:repair")
12867
+ .description("Locally resume failed account-search slices from their stored offsets.")
12868
+ .requiredOption("--run-id <id>", "Sales Navigator account-search run id")
12869
+ .requiredOption("--curl-file <path>", "Path to a saved account-search curl request used as the local request template")
12870
+ .option("--max-slices <number>", "Maximum failed slices to repair in this invocation", "100")
12871
+ .option("--number-of-accounts <number>", "Maximum accounts to fetch per repair slice, or 'all' for each slice remainder", "all")
12872
+ .option("--max-repair-results <number>", "Skip zero-progress failed slices above this reported result count; those still need splitting", "1000")
12873
+ .option("--page-size <number>", "Accounts requested per Sales Navigator account-search API page", "25")
12874
+ .option("--page-delay-min-ms <number>", "Minimum randomized delay between account-search result pages", "20000")
12875
+ .option("--page-delay-max-ms <number>", "Maximum randomized delay between account-search result pages", "35000")
12876
+ .option("--max-retries <number>", "Bounded retries for transient network/5xx failures. Rate limits still stop immediately.", "1")
12877
+ .option("--retry-base-delay-ms <number>", "Base delay for full-jitter retry backoff", "2000")
12878
+ .option("--retry-max-delay-ms <number>", "Maximum delay for full-jitter retry backoff", "30000")
12879
+ .option("--out <path>", "Optional local JSON output path")
12880
+ .action(async (options) => {
12881
+ const runId = z.string().uuid().parse(options.runId);
12882
+ const maxSlices = z.coerce
12883
+ .number()
12884
+ .int()
12885
+ .min(1)
12886
+ .max(1000)
12887
+ .parse(options.maxSlices);
12888
+ const numberOfAccountsOption = String(options.numberOfAccounts ?? "all")
12889
+ .trim()
12890
+ .toLowerCase();
12891
+ const requestedAccountsOption = numberOfAccountsOption === "all"
12892
+ ? null
12893
+ : z.coerce
12894
+ .number()
12895
+ .int()
12896
+ .min(1)
12897
+ .max(100000)
12898
+ .parse(options.numberOfAccounts);
12899
+ const pageSize = z.coerce
12900
+ .number()
12901
+ .int()
12902
+ .min(1)
12903
+ .max(100)
12904
+ .parse(options.pageSize);
12905
+ const maxRepairResults = z.coerce
12906
+ .number()
12907
+ .int()
12908
+ .min(1)
12909
+ .max(100000)
12910
+ .parse(options.maxRepairResults);
12911
+ const pageDelayMinMs = z.coerce
12912
+ .number()
12913
+ .int()
12914
+ .min(0)
12915
+ .max(300000)
12916
+ .parse(options.pageDelayMinMs);
12917
+ const pageDelayMaxMs = z.coerce
12918
+ .number()
12919
+ .int()
12920
+ .min(0)
12921
+ .max(300000)
12922
+ .parse(options.pageDelayMaxMs);
12923
+ if (pageDelayMaxMs < pageDelayMinMs) {
12924
+ throw new Error("--page-delay-max-ms must be greater than or equal to --page-delay-min-ms.");
12925
+ }
12926
+ const retry = {
12927
+ maxRetries: z.coerce
12928
+ .number()
12929
+ .int()
12930
+ .min(0)
12931
+ .max(5)
12932
+ .parse(options.maxRetries),
12933
+ retryBaseDelayMs: z.coerce
12934
+ .number()
12935
+ .int()
12936
+ .min(0)
12937
+ .max(300000)
12938
+ .parse(options.retryBaseDelayMs),
12939
+ retryMaxDelayMs: z.coerce
12940
+ .number()
12941
+ .int()
12942
+ .min(0)
12943
+ .max(300000)
12944
+ .parse(options.retryMaxDelayMs),
12945
+ };
12946
+ const templateRequest = parseSalesNavigatorCurlRequest(await readFile(options.curlFile, "utf8"));
12947
+ if (!new URL(templateRequest.url).pathname.includes("/sales-api/salesApiAccountSearch")) {
12948
+ throw new Error("Account repair --curl-file must contain a /sales-api/salesApiAccountSearch request.");
12949
+ }
12950
+ const store = await createSalesNavigatorAccountSearchStore();
12951
+ const repaired = [];
12952
+ const skipped = [];
12953
+ let stoppedByRateLimit = null;
12954
+ try {
12955
+ const slices = await listSalesNavigatorAccountSearchRepairSlices({
12956
+ store,
12957
+ runId,
12958
+ limit: maxSlices,
12959
+ });
12960
+ for (const slice of slices) {
12961
+ const startOffset = resolveAccountRepairStartOffset(slice);
12962
+ const remainingFromReported = slice.totalResults == null
12963
+ ? null
12964
+ : Math.max(0, slice.totalResults - startOffset);
12965
+ if (remainingFromReported === 0) {
12966
+ continue;
12967
+ }
12968
+ if (startOffset === 0 &&
12969
+ slice.storedAccounts === 0 &&
12970
+ slice.totalResults != null &&
12971
+ slice.totalResults > maxRepairResults) {
12972
+ skipped.push({
12973
+ sliceId: slice.id,
12974
+ slicedQueryUrl: slice.slicedQueryUrl,
12975
+ totalResults: slice.totalResults,
12976
+ storedAccounts: slice.storedAccounts,
12977
+ reason: `reported total ${slice.totalResults} is above max repair results ${maxRepairResults}`,
12978
+ });
12979
+ continue;
12980
+ }
12981
+ if (startOffset === 0 &&
12982
+ slice.storedAccounts === 0 &&
12983
+ !asJsonObject(slice.rawPayload).stop &&
12984
+ /\b(429|rate[-\s]?limit|too many requests)\b/i.test(slice.lastError ?? "")) {
12985
+ skipped.push({
12986
+ sliceId: slice.id,
12987
+ slicedQueryUrl: slice.slicedQueryUrl,
12988
+ totalResults: slice.totalResults,
12989
+ storedAccounts: slice.storedAccounts,
12990
+ reason: "zero-progress slice has no resume offset and the previous failure was rate limited",
12991
+ });
12992
+ continue;
12993
+ }
12994
+ const requestedAccounts = requestedAccountsOption == null
12995
+ ? remainingFromReported
12996
+ : remainingFromReported == null
12997
+ ? requestedAccountsOption
12998
+ : Math.min(requestedAccountsOption, remainingFromReported);
12999
+ const parsedRequest = buildSalesNavigatorAccountApiRequestFromSearchUrlWithTemplate(slice.slicedQueryUrl, templateRequest, pageSize);
13000
+ const fetchResult = await fetchAllLocalSalesNavigatorAccounts(parsedRequest, {
13001
+ requestedAccounts,
13002
+ startOffset,
13003
+ pageSize,
13004
+ pageDelayMinMs,
13005
+ pageDelayMaxMs,
13006
+ maxAllowedResults: 100000,
13007
+ retry,
13008
+ allowPartialPageErrors: true,
13009
+ });
13010
+ const rateLimit = fetchResult.pacing.rateLimit;
13011
+ const pageError = fetchResult.pacing.pageError;
13012
+ const partialError = describeLocalSalesNavigatorAccountPartialError(fetchResult);
13013
+ const cumulativeStoredAccounts = slice.storedAccounts + fetchResult.accounts.length;
13014
+ const attempt = repairSliceToCrawlAttempt({
13015
+ ...slice,
13016
+ maxResultsPerSearch: fetchResult.totalResults ??
13017
+ slice.totalResults ??
13018
+ slice.maxResultsPerSearch,
13019
+ pageSize,
13020
+ });
13021
+ const sliceId = await upsertSalesNavigatorAccountSearchSlice({
13022
+ store,
13023
+ runId,
13024
+ orgId: slice.orgId,
13025
+ attempt,
13026
+ status: partialError ? "failed" : "finished",
13027
+ totalResults: fetchResult.totalResults ?? slice.totalResults,
13028
+ fetchedPages: slice.fetchedPages + fetchResult.fetchedPages,
13029
+ storedAccounts: cumulativeStoredAccounts,
13030
+ retryCount: slice.retryCount + fetchResult.pacing.retryCount,
13031
+ retryDelayMs: slice.retryDelayMs + fetchResult.pacing.totalDelayMs,
13032
+ lastError: partialError,
13033
+ rawPayload: {
13034
+ ...slice.rawPayload,
13035
+ repair: {
13036
+ repairedAt: new Date().toISOString(),
13037
+ previousStoredAccounts: slice.storedAccounts,
13038
+ startOffset,
13039
+ fetchedAccounts: fetchResult.accounts.length,
13040
+ },
13041
+ pacing: fetchResult.pacing,
13042
+ stop: fetchResult.pacing.stop,
13043
+ },
13044
+ });
13045
+ const storedAccounts = await upsertSalesNavigatorAccounts({
13046
+ store,
13047
+ orgId: slice.orgId,
13048
+ runId,
13049
+ sliceId,
13050
+ accounts: fetchResult.accounts,
13051
+ });
13052
+ repaired.push({
13053
+ sliceId,
13054
+ slicedQueryUrl: slice.slicedQueryUrl,
13055
+ startOffset,
13056
+ totalResults: fetchResult.totalResults ?? slice.totalResults,
13057
+ fetchedPages: fetchResult.fetchedPages,
13058
+ fetchedAccounts: fetchResult.accounts.length,
13059
+ storedAccounts,
13060
+ cumulativeStoredAccounts,
13061
+ status: partialError ? "failed" : "finished",
13062
+ stop: fetchResult.pacing.stop,
13063
+ error: partialError,
13064
+ });
13065
+ if (rateLimit) {
13066
+ stoppedByRateLimit = {
13067
+ sliceId,
13068
+ message: rateLimit.message,
13069
+ };
13070
+ break;
13071
+ }
13072
+ }
13073
+ const summary = await summarizeSalesNavigatorAccountSearchRunFromSlices({
13074
+ store,
13075
+ runId,
13076
+ });
13077
+ await updateSalesNavigatorAccountSearchRun({
13078
+ store,
13079
+ runId,
13080
+ status: summary.truncated ? "failed" : "finished",
13081
+ totalSlices: summary.totalSlices,
13082
+ storedAccounts: summary.storedAccounts,
13083
+ attemptedSlices: summary.attemptedSlices,
13084
+ splitEvents: summary.splitEvents,
13085
+ failedSlices: summary.failedSlices,
13086
+ unresolvedSlices: summary.unresolvedSlices,
13087
+ truncated: summary.truncated,
13088
+ lastError: stoppedByRateLimit?.message ?? summary.lastError,
13089
+ rawPayload: {
13090
+ lastRepair: {
13091
+ repairedAt: new Date().toISOString(),
13092
+ repairedSlices: repaired.length,
13093
+ skippedSlices: skipped.length,
13094
+ stoppedByRateLimit,
13095
+ },
13096
+ },
13097
+ });
13098
+ const payload = {
13099
+ status: stoppedByRateLimit ? "rate_limited" : "ok",
13100
+ mode: "account-repair",
13101
+ runId,
13102
+ inspectedSlices: repaired.length + skipped.length,
13103
+ repaired,
13104
+ skipped,
13105
+ stoppedByRateLimit,
13106
+ run: summary,
13107
+ };
13108
+ if (options.out) {
13109
+ await writeJsonFile(options.out, payload);
13110
+ }
13111
+ printOutput(payload);
13112
+ if (stoppedByRateLimit) {
13113
+ throw new Error(stoppedByRateLimit.message);
13114
+ }
13115
+ }
13116
+ finally {
13117
+ await closeSalesNavigatorAccountSearchStore(store);
13118
+ }
13119
+ });
13120
+ program
13121
+ .command("salesnav:accounts:status")
13122
+ .alias("accounts:status")
13123
+ .description("Return the current status of a Sales Navigator account-search run.")
13124
+ .requiredOption("--run-id <id>", "Sales Navigator account-search run id")
13125
+ .option("--cooldown-seconds <number>", "Seconds to wait after a rate-limit before resuming", "3600")
13126
+ .action(async (options) => {
13127
+ const runId = z.string().uuid().parse(options.runId);
13128
+ const cooldownSeconds = z.coerce
13129
+ .number()
13130
+ .int()
13131
+ .min(60)
13132
+ .max(86_400)
13133
+ .parse(options.cooldownSeconds);
13134
+ const session = await requireAuthSession();
13135
+ const status = await getSalesNavigatorAccountRunStatus(session, runId, {
13136
+ cooldownSeconds
13137
+ });
13138
+ printOutput({
13139
+ status: "ok",
13140
+ runId,
13141
+ run: status.value.run,
13142
+ workflow: {
13143
+ ...status.value.run.workflow,
13144
+ repairCommand: status.value.run.nextAction === "complete"
13145
+ ? null
13146
+ : buildSalesNavigatorAccountRepairCommand({ runId }),
13147
+ },
13148
+ stoppedSlices: status.value.run.problemSlices.map((slice) => ({
13149
+ sliceId: slice.sliceId,
13150
+ status: slice.status,
13151
+ totalResults: slice.totalResults,
13152
+ storedAccounts: slice.storedAccounts,
13153
+ stopReason: slice.stopReason,
13154
+ nextStart: slice.nextStart,
13155
+ missingFromReported: slice.missingFromReported,
13156
+ lastError: slice.lastError,
13157
+ slicedQueryUrl: slice.slicedQueryUrl
13158
+ }))
13159
+ });
13160
+ });
13161
+ program
13162
+ .command("salesnav:accounts:resume")
13163
+ .alias("accounts:resume")
13164
+ .description("Start the durable account-search coordinator for a previous account run.")
13165
+ .requiredOption("--run-id <id>", "Sales Navigator account-search run id")
13166
+ .option("--cooldown-seconds <number>", "Seconds to wait after a rate-limit before resuming", "3600")
13167
+ .option("--status-only", "Only inspect the run and print the next action without starting a Vercel workflow", false)
13168
+ .action(async (options) => {
13169
+ const runId = z.string().uuid().parse(options.runId);
13170
+ const cooldownSeconds = z.coerce
13171
+ .number()
13172
+ .int()
13173
+ .min(60)
13174
+ .max(86_400)
13175
+ .parse(options.cooldownSeconds);
13176
+ const session = await requireAuthSession();
13177
+ const status = await getSalesNavigatorAccountRunStatus(session, runId, {
13178
+ cooldownSeconds
13179
+ });
13180
+ if (options.statusOnly) {
13181
+ printOutput({
13182
+ status: "ok",
13183
+ runId,
13184
+ startedWorkflow: false,
13185
+ nextAction: status.value.run.nextAction,
13186
+ run: status.value.run,
13187
+ workflow: {
13188
+ ...status.value.run.workflow,
13189
+ repairCommand: status.value.run.nextAction === "complete"
13190
+ ? null
13191
+ : buildSalesNavigatorAccountRepairCommand({ runId }),
13192
+ },
13193
+ });
13194
+ return;
13195
+ }
13196
+ const workflow = await startSalesNavigatorAccountRunWorkflow(status.session, runId, { cooldownSeconds });
13197
+ printOutput({
13198
+ status: "accepted",
13199
+ runId,
13200
+ workflowRunId: workflow.value.workflowRunId,
13201
+ nextAction: status.value.run.nextAction,
13202
+ cooldownUntil: status.value.run.cooldownUntil,
13203
+ repairCommand: buildSalesNavigatorAccountRepairCommand({ runId }),
13204
+ problemSlices: status.value.run.problemSlices
13205
+ });
13206
+ });
10623
13207
  program
10624
13208
  .command("phantombuster:containers:sync")
10625
13209
  .alias("pb:containers:sync")
10626
13210
  .description("Fetch Phantombuster containers for configured agents and store them in Neon.")
10627
13211
  .option("--agent-id <id>", "Phantombuster agent id to sync. Repeat to sync multiple agents.", collectStringOptionValue, [])
10628
- .option("--container-id <id>", "Specific Phantombuster container id to sync. Repeat to sync multiple containers.", collectStringOptionValue, [])
10629
13212
  .option("--limit <number>", "Maximum containers to fetch per Phantombuster page", "100")
10630
13213
  .option("--max-pages <number>", "Maximum Phantombuster pages to fetch per agent", "50")
10631
13214
  .option("--mode <mode>", "Phantombuster container mode: all or finalized", "all")
10632
13215
  .option("--before-ended-at <iso>", "Only fetch containers that ended before this ISO timestamp")
10633
13216
  .option("--metadata-only", "Store container metadata without fetching output and result objects", false)
10634
- .option("--refresh-lead-pool", "After syncing results, rebuild the Neon lead_pool_new reporting table. This can take several minutes.", false)
10635
13217
  .option("--out <path>", "Optional local JSON output path")
10636
13218
  .action(async (options) => {
10637
13219
  const agentIds = z.array(z.string().min(1)).parse(options.agentId);
10638
- const containerIds = z.array(z.string().min(1)).parse(options.containerId);
10639
13220
  const limit = z.coerce.number().int().min(1).max(500).parse(options.limit);
10640
13221
  const maxPages = z.coerce.number().int().min(1).max(500).parse(options.maxPages);
10641
13222
  const mode = z.enum(["all", "finalized"]).parse(options.mode);
@@ -10645,13 +13226,11 @@ program
10645
13226
  const session = await requireAuthSession();
10646
13227
  const result = await syncPhantombusterContainersViaApp(session, {
10647
13228
  agentIds: agentIds.length > 0 ? agentIds : undefined,
10648
- containerIds: containerIds.length > 0 ? containerIds : undefined,
10649
13229
  limit,
10650
13230
  maxPages,
10651
13231
  mode,
10652
13232
  beforeEndedAt,
10653
- includeResults: !options.metadataOnly,
10654
- refreshLeadPool: Boolean(options.refreshLeadPool)
13233
+ includeResults: !options.metadataOnly
10655
13234
  });
10656
13235
  const payload = {
10657
13236
  ...result,
@@ -10737,100 +13316,6 @@ program
10737
13316
  }
10738
13317
  printOutput(payload);
10739
13318
  });
10740
- program
10741
- .command("salesnav:local-import")
10742
- .alias("search:local-import")
10743
- .description("Run a saved Sales Navigator API request locally and store the people in Salesprompter.")
10744
- .option("--curl-file <path>", "Path to a saved curl request copied from the Sales Navigator network panel")
10745
- .option("--query-url <url>", "Sales Navigator people search URL. Uses the locally captured extension session automatically.")
10746
- .option("--max-results-per-search <number>", "Maximum expected results for this local request", "100")
10747
- .option("--number-of-profiles <number>", "Total profiles to import, or 'all' for the full reported result set", "all")
10748
- .option("--page-size <number>", "Profiles requested per local Sales Navigator API page", "25")
10749
- .option("--page-delay-min-ms <number>", "Minimum randomized delay between Sales Navigator result pages", "1500")
10750
- .option("--page-delay-max-ms <number>", "Maximum randomized delay between Sales Navigator result pages", "6000")
10751
- .option("--max-retries <number>", "Bounded retries for transient network/5xx failures. Rate limits still stop immediately.", "2")
10752
- .option("--retry-base-delay-ms <number>", "Base delay for full-jitter retry backoff", "2000")
10753
- .option("--retry-max-delay-ms <number>", "Maximum delay for full-jitter retry backoff", "30000")
10754
- .option("--slice-preset <name>", "Slice preset label stored with the import run", "local-salesnav")
10755
- .option("--out <path>", "Optional local JSON output path")
10756
- .option("--dry-run", "Run and normalize the local request without writing to Salesprompter", false)
10757
- .action(async (options) => {
10758
- const maxResultsPerSearch = z.coerce.number().int().min(1).max(100000).parse(options.maxResultsPerSearch);
10759
- const numberOfProfilesOption = String(options.numberOfProfiles ?? "all").trim().toLowerCase();
10760
- const requestedProfiles = numberOfProfilesOption === "all" ? null : z.coerce.number().int().min(1).max(100000).parse(options.numberOfProfiles);
10761
- const pageSize = z.coerce.number().int().min(1).max(100).parse(options.pageSize);
10762
- const pageDelayMinMs = z.coerce.number().int().min(0).max(300000).parse(options.pageDelayMinMs);
10763
- const pageDelayMaxMs = z.coerce.number().int().min(0).max(300000).parse(options.pageDelayMaxMs);
10764
- if (pageDelayMaxMs < pageDelayMinMs) {
10765
- throw new Error("--page-delay-max-ms must be greater than or equal to --page-delay-min-ms.");
10766
- }
10767
- const retry = {
10768
- maxRetries: z.coerce.number().int().min(0).max(5).parse(options.maxRetries),
10769
- retryBaseDelayMs: z.coerce.number().int().min(0).max(300000).parse(options.retryBaseDelayMs),
10770
- retryMaxDelayMs: z.coerce.number().int().min(0).max(300000).parse(options.retryMaxDelayMs)
10771
- };
10772
- const curlFile = typeof options.curlFile === "string" && options.curlFile.trim().length > 0 ? options.curlFile.trim() : null;
10773
- const queryUrl = typeof options.queryUrl === "string" && options.queryUrl.trim().length > 0 ? z.string().url().parse(options.queryUrl.trim()) : null;
10774
- if (Boolean(curlFile) === Boolean(queryUrl)) {
10775
- throw new Error("Provide exactly one of --curl-file or --query-url.");
10776
- }
10777
- const parsedRequest = curlFile
10778
- ? parseSalesNavigatorCurlRequest(await readFile(curlFile, "utf8"))
10779
- : buildSalesNavigatorApiRequestFromSearchUrl(queryUrl, await readLinkedInDirectLookupConfig(), pageSize);
10780
- const localFetch = await fetchAllLocalSalesNavigatorPeople(parsedRequest, {
10781
- requestedProfiles,
10782
- pageSize,
10783
- pageDelayMinMs,
10784
- pageDelayMaxMs,
10785
- retry
10786
- });
10787
- const people = localFetch.people;
10788
- const totalResults = localFetch.totalResults;
10789
- const dryRun = Boolean(options.dryRun);
10790
- if (people.length === 0) {
10791
- throw new Error("The local Sales Navigator response did not contain any importable people rows.");
10792
- }
10793
- const basePayload = {
10794
- status: "ok",
10795
- dryRun,
10796
- sourceQueryUrl: localFetch.sourceQueryUrl,
10797
- totalResults,
10798
- discovered: people.length,
10799
- fetchedPages: localFetch.fetchedPages,
10800
- requestedProfiles: requestedProfiles ?? "all",
10801
- pacing: localFetch.pacing,
10802
- people
10803
- };
10804
- if (dryRun) {
10805
- if (options.out) {
10806
- await writeJsonFile(options.out, basePayload);
10807
- }
10808
- printOutput(basePayload);
10809
- return;
10810
- }
10811
- const session = await requireAuthSession();
10812
- const imported = await importLocalSalesNavigatorPeopleViaApp(session, {
10813
- sourceQueryUrl: localFetch.sourceQueryUrl,
10814
- people,
10815
- totalResults,
10816
- fetchedPages: localFetch.fetchedPages,
10817
- pacing: localFetch.pacing,
10818
- maxResultsPerSearch,
10819
- numberOfProfiles: requestedProfiles ?? people.length,
10820
- slicePreset: options.slicePreset
10821
- });
10822
- const payload = {
10823
- ...basePayload,
10824
- runId: imported.runId,
10825
- containerId: imported.containerId,
10826
- imported: imported.imported,
10827
- upserted: imported.upserted
10828
- };
10829
- if (options.out) {
10830
- await writeJsonFile(options.out, payload);
10831
- }
10832
- printOutput(payload);
10833
- });
10834
13319
  program
10835
13320
  .command("salesnav:count")
10836
13321
  .alias("search:count")
@@ -11493,54 +13978,6 @@ program
11493
13978
  execution
11494
13979
  });
11495
13980
  });
11496
- program
11497
- .command("affiliate:launch")
11498
- .description("Turn an affiliate link and LinkedIn audience URL into a ready-to-review outreach campaign.")
11499
- .requiredOption("--affiliate-link <url>", "Affiliate landing URL, including the tracking/referral parameter")
11500
- .requiredOption("--linkedin-url <url>", "LinkedIn content/search URL for the audience")
11501
- .option("--max-results <number>", "Maximum audience profiles to extract", "1000")
11502
- .option("--dry-run", "Validate inputs and show the planned campaign without launching extraction", false)
11503
- .action(async (options) => {
11504
- const session = await requireAuthSession();
11505
- const result = await launchAffiliateCampaignViaApp(session, {
11506
- affiliateLink: z.string().url().parse(options.affiliateLink),
11507
- linkedInUrl: z.string().url().parse(options.linkedinUrl),
11508
- maxResults: z.coerce.number().int().min(1).max(1000).parse(options.maxResults),
11509
- dryRun: Boolean(options.dryRun)
11510
- });
11511
- printOutput(result);
11512
- });
11513
- program
11514
- .command("leads:process")
11515
- .description("Automatically process the lead pool: companies, contact cleanup, email finding, and qualified leads.")
11516
- .option("--client-id <number>", "Limit processing to one Salesprompter clientId")
11517
- .option("--company-limit <number>", "Max companies to start per enrichment batch", "10")
11518
- .option("--contact-country-limit <number>", "Max contact country-code rows to process", "500")
11519
- .option("--contact-name-limit <number>", "Max contact name rows to clean", "250")
11520
- .option("--hunter-limit <number>", "Max Hunter email-finder rows to process", "500")
11521
- .option("--hunter-concurrency <number>", "Hunter email-finder concurrency", "6")
11522
- .option("--refresh", "Run a full lead-pool refresh after processing", false)
11523
- .option("--no-qualified-leads", "Skip rebuilding the qualified lead snapshot")
11524
- .action(async (options) => {
11525
- const session = await requireAuthSession();
11526
- const clientId = options.clientId == null || String(options.clientId).trim() === ''
11527
- ? null
11528
- : parsePositiveClientIdValue(options.clientId);
11529
- const result = await runLeadPoolAutoProcessViaApp(session, {
11530
- clientId,
11531
- companyLimit: z.coerce.number().int().min(1).max(50).parse(options.companyLimit),
11532
- contactCountryLimit: z.coerce.number().int().min(1).max(5000).parse(options.contactCountryLimit),
11533
- contactNameLimit: z.coerce.number().int().min(1).max(250).parse(options.contactNameLimit),
11534
- hunterLimit: z.coerce.number().int().min(1).max(10000).parse(options.hunterLimit),
11535
- hunterConcurrency: z.coerce.number().int().min(1).max(12).parse(options.hunterConcurrency),
11536
- refresh: Boolean(options.refresh),
11537
- qualifiedLeads: options.qualifiedLeads !== false,
11538
- });
11539
- printOutput({
11540
- status: result.success ? "ok" : "partial",
11541
- ...result,
11542
- });
11543
- });
11544
13981
  program
11545
13982
  .command("contacts:process-emails")
11546
13983
  .alias("contacts:resolve-emails")
@@ -11552,8 +13989,6 @@ program
11552
13989
  .requiredOption("--out-dir <path>", "Output directory for artifacts")
11553
13990
  .option("--trace-id <traceId>", "Trace id for this batch")
11554
13991
  .option("--endpoint-url <url>", "Override the enrichment workflow endpoint")
11555
- .option("--provider <provider>", "Email finder provider for workspace queue: hunter or phantombuster", "hunter")
11556
- .option("--retry-not-found-after-days <number>", "Retry stale Email not found rows after this many days", "1")
11557
13992
  .option("--timeout-ms <number>", "Workflow trigger timeout in milliseconds", "30000")
11558
13993
  .option("--company-cleaning <mode>", "Company cleaning mode for direct input: off, basic, or ai", "basic")
11559
13994
  .option("--dry-run", "Preview the next batch without starting background processing", false)
@@ -11562,69 +13997,7 @@ program
11562
13997
  const outDir = z.string().min(1).parse(options.outDir);
11563
13998
  const timeoutMs = z.coerce.number().int().min(1000).max(300000).parse(options.timeoutMs);
11564
13999
  const usingDirectInput = typeof options.in === "string" && options.in.trim().length > 0;
11565
- const provider = z.enum(["hunter", "phantombuster"]).parse(String(options.provider ?? "hunter").trim().toLowerCase());
11566
- const retryNotFoundAfterDays = z.coerce.number().int().min(0).max(30).parse(options.retryNotFoundAfterDays);
11567
14000
  const companyCleaningMode = resolveCompanyCleaningMode(String(options.companyCleaning ?? process.env.SALESPROMPTER_COMPANY_CLEANING_MODE ?? "basic"));
11568
- if (provider === "phantombuster" && !usingDirectInput) {
11569
- await mkdir(outDir, { recursive: true });
11570
- const traceId = z.string().min(1).parse(options.traceId ?? `salesprompter-cli-email-phantombuster-${Date.now()}`);
11571
- const requestPath = `${outDir}/email-enrichment-request-phantombuster.json`;
11572
- const responsePath = `${outDir}/email-enrichment-response-phantombuster.json`;
11573
- const requestPayload = {
11574
- traceId,
11575
- provider,
11576
- limit,
11577
- retryNotFoundAfterDays
11578
- };
11579
- await writeJsonFile(requestPath, requestPayload);
11580
- if (options.dryRun) {
11581
- printOutput({
11582
- status: "ok",
11583
- provider,
11584
- limit,
11585
- traceId,
11586
- dryRun: true,
11587
- mode: "workspace-queue",
11588
- contactsQueued: null,
11589
- started: false,
11590
- artifacts: {
11591
- inputSqlPath: null,
11592
- inputRowsPath: null,
11593
- directReportPath: null,
11594
- requestPath,
11595
- responsePath: null
11596
- }
11597
- });
11598
- return;
11599
- }
11600
- const session = await requireAuthSession();
11601
- const result = await runLeadPoolEmailFinderViaApp(session, {
11602
- provider,
11603
- limit,
11604
- retryNotFoundAfterDays
11605
- });
11606
- await writeJsonFile(responsePath, result);
11607
- printOutput({
11608
- status: result.success ? "ok" : "partial",
11609
- provider,
11610
- limit,
11611
- traceId,
11612
- dryRun: false,
11613
- mode: "workspace-queue",
11614
- contactsQueued: result.selected,
11615
- started: Boolean(result.launched),
11616
- agentId: result.agentId ?? null,
11617
- containerId: result.containerId ?? null,
11618
- artifacts: {
11619
- inputSqlPath: null,
11620
- inputRowsPath: null,
11621
- directReportPath: null,
11622
- requestPath,
11623
- responsePath
11624
- }
11625
- });
11626
- return;
11627
- }
11628
14001
  let clientId;
11629
14002
  let queueRows;
11630
14003
  let directReportRows = null;