salesprompter-cli 0.1.39 → 0.1.40

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.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/dist/cli.js +778 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -60,6 +60,9 @@ salesprompter contacts:find-linkedin-urls --in ./contacts.tsv --out ./contacts.e
60
60
  # Resolve LinkedIn company URLs for a pasted company list
61
61
  salesprompter companies:resolve-linkedin-urls --in ./companies.txt --out ./companies.enriched.json
62
62
 
63
+ # Import authorized local Sales Navigator people-search results into Salesprompter
64
+ salesprompter search:local-import --query-url '<sales navigator people search URL>' --number-of-profiles all
65
+
63
66
  # Build and start email enrichment from pasted company/contact rows
64
67
  # When authenticated, Salesprompter first enriches company domains in the app, then the CLI builds the email batch.
65
68
  salesprompter contacts:resolve-emails --in ./contacts.tsv --out-dir ./email-run --dry-run
@@ -80,6 +83,7 @@ salesprompter --help
80
83
 
81
84
  - Use your own authorized data access and workspace credentials.
82
85
  - Respect provider terms and customer data boundaries.
86
+ - Local Sales Navigator import requires an authorized Sales Navigator session captured from the user's own browser context.
83
87
  - The CLI is designed for interactive users and agent-assisted workflows.
84
88
  - A repo-local Chrome extension compatibility copy is available in `chrome-extension/` for local LinkedIn session sync and popup copy/debug flows.
85
89
 
package/dist/cli.js CHANGED
@@ -124,6 +124,56 @@ const LinkedInCompanyBackfillStatusResponseSchema = z.object({
124
124
  failureCode: z.string().nullable().optional(),
125
125
  failureMessage: z.string().nullable().optional()
126
126
  });
127
+ const SalesNavigatorLocalImportResponseSchema = z.object({
128
+ status: z.literal("ok"),
129
+ runId: z.string().min(1),
130
+ containerId: z.string().min(1),
131
+ 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
155
+ .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()
171
+ })
172
+ .optional(),
173
+ previewUrl: z.string().min(1).nullable().optional(),
174
+ next: z.string().optional()
175
+ })
176
+ .passthrough();
127
177
  const LeadPoolEmailFinderProcessResponseSchema = z.object({
128
178
  success: z.boolean(),
129
179
  provider: z.string().optional(),
@@ -141,6 +191,7 @@ const LeadPoolEmailFinderProcessResponseSchema = z.object({
141
191
  const PhantombusterContainersSyncResponseSchema = z.object({
142
192
  status: z.literal("ok"),
143
193
  agentIds: z.array(z.string().min(1)),
194
+ containerIds: z.array(z.string().min(1)).optional(),
144
195
  agents: z.array(z.object({
145
196
  agentId: z.string().min(1),
146
197
  fetched: z.number().int().nonnegative(),
@@ -340,21 +391,21 @@ const cliPacks = [
340
391
  slug: "research",
341
392
  title: "Research",
342
393
  summary: "Scrape markets and enrich companies before outreach.",
343
- commands: ["market:scrape", "companies:enrich"],
394
+ commands: ["market:scrape", "companies:enrich", "leads:process"],
344
395
  installStatus: "included"
345
396
  },
346
397
  {
347
398
  slug: "discovery",
348
399
  title: "Discovery",
349
400
  summary: "Find leads from product and market inputs.",
350
- commands: ["leads:discover", "search:run", "search:status", "search:export", "search:count"],
401
+ commands: ["leads:discover", "search:run", "search:status", "search:export", "search:local-import", "search:count"],
351
402
  installStatus: "included"
352
403
  },
353
404
  {
354
405
  slug: "outreach",
355
406
  title: "Outreach",
356
407
  summary: "Prepare and sync qualified leads into downstream systems.",
357
- commands: ["sync:outreach", "sync:crm"],
408
+ commands: ["affiliate:launch", "sync:outreach", "sync:crm"],
358
409
  installStatus: "included"
359
410
  }
360
411
  ];
@@ -368,6 +419,7 @@ const helpAliasByCommandName = new Map([
368
419
  ["salesnav:crawl", "search:run"],
369
420
  ["salesnav:crawl:status", "search:status"],
370
421
  ["salesnav:export", "search:export"],
422
+ ["salesnav:local-import", "search:local-import"],
371
423
  ["salesnav:count", "search:count"]
372
424
  ]);
373
425
  const helpVisibleCommandNames = new Set([
@@ -391,7 +443,9 @@ const helpVisibleCommandNames = new Set([
391
443
  "leads:generate",
392
444
  "leads:enrich",
393
445
  "leads:score",
446
+ "leads:process",
394
447
  "leads:pipeline",
448
+ "affiliate:launch",
395
449
  "sync:crm",
396
450
  "sync:outreach",
397
451
  "linkedin-companies:backfill",
@@ -400,6 +454,7 @@ const helpVisibleCommandNames = new Set([
400
454
  "salesnav:crawl",
401
455
  "salesnav:crawl:status",
402
456
  "salesnav:export",
457
+ "salesnav:local-import",
403
458
  "salesnav:count"
404
459
  ]);
405
460
  function printOutput(value) {
@@ -1327,12 +1382,22 @@ async function listChromeExtensionTokenLogCandidates() {
1327
1382
  if (!file.endsWith(".log")) {
1328
1383
  continue;
1329
1384
  }
1330
- paths.push(path.join(extensionDir, file));
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 });
1331
1394
  }
1332
1395
  }
1333
1396
  }
1334
1397
  }
1335
- return paths.sort().reverse();
1398
+ return paths
1399
+ .sort((left, right) => right.mtimeMs - left.mtimeMs || right.path.localeCompare(left.path))
1400
+ .map((entry) => entry.path);
1336
1401
  }
1337
1402
  async function readLocalLinkedInExtensionDirectLookupConfig() {
1338
1403
  const candidates = await listChromeExtensionTokenLogCandidates();
@@ -1430,6 +1495,9 @@ async function readLinkedInDirectLookupConfig() {
1430
1495
  cachedLinkedInDirectLookupConfig = envConfig;
1431
1496
  return envConfig;
1432
1497
  }
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.");
1500
+ }
1433
1501
  const localExtensionConfig = await readLocalLinkedInExtensionDirectLookupConfig();
1434
1502
  if (localExtensionConfig) {
1435
1503
  cachedLinkedInDirectLookupConfig = localExtensionConfig;
@@ -5722,26 +5790,558 @@ async function fetchLinkedInCompaniesBackfillStatus(session, payload) {
5722
5790
  }), LinkedInCompanyBackfillStatusResponseSchema);
5723
5791
  return value;
5724
5792
  }
5725
- async function syncPhantombusterContainersViaApp(session, payload) {
5726
- const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/phantombuster/containers/sync`, {
5793
+ class LocalSalesNavigatorRateLimitError extends Error {
5794
+ constructor(status, bodyPreview) {
5795
+ super(`Sales Navigator request stopped because LinkedIn returned HTTP ${status}. ` +
5796
+ "Use LinkedIn directly in a supported browser and wait before trying again. " +
5797
+ `Response preview: ${bodyPreview}`);
5798
+ this.name = "LocalSalesNavigatorRateLimitError";
5799
+ }
5800
+ }
5801
+ function stripShellQuotes(value) {
5802
+ const trimmed = value.trim();
5803
+ if (trimmed.length >= 2) {
5804
+ const quote = trimmed[0];
5805
+ if ((quote === "'" || quote === '"') && trimmed.at(-1) === quote) {
5806
+ return trimmed.slice(1, -1);
5807
+ }
5808
+ }
5809
+ return trimmed;
5810
+ }
5811
+ function parseCurlHeaderValue(value) {
5812
+ const separatorIndex = value.indexOf(":");
5813
+ if (separatorIndex <= 0) {
5814
+ return null;
5815
+ }
5816
+ const name = value.slice(0, separatorIndex).trim().toLowerCase();
5817
+ const headerValue = value.slice(separatorIndex + 1).trim();
5818
+ if (!name || name === "cookie") {
5819
+ return null;
5820
+ }
5821
+ return [name, headerValue];
5822
+ }
5823
+ function parseSalesNavigatorCurlRequest(curlText) {
5824
+ const urlMatch = curlText.match(/\bcurl\s+(?:--location\s+|-L\s+)?(['"])(https?:\/\/[\s\S]*?)\1/);
5825
+ const fallbackUrlMatch = curlText.match(/\bcurl\s+(https?:\/\/\S+)/);
5826
+ const url = stripShellQuotes(urlMatch?.[2] ?? fallbackUrlMatch?.[1] ?? "");
5827
+ if (!url) {
5828
+ throw new Error("Could not find a URL in the curl request.");
5829
+ }
5830
+ const headers = {};
5831
+ for (const match of curlText.matchAll(/(?:-H|--header)\s+(['"])([\s\S]*?)\1/g)) {
5832
+ const parsed = parseCurlHeaderValue(match[2] ?? "");
5833
+ if (parsed) {
5834
+ headers[parsed[0]] = parsed[1];
5835
+ }
5836
+ }
5837
+ const cookieMatch = curlText.match(/(?:-b|--cookie)\s+(['"])([\s\S]*?)\1/) ??
5838
+ curlText.match(/(?:-b|--cookie)\s+([^\s\\]+)/);
5839
+ const cookie = stripShellQuotes(cookieMatch?.[2] ?? cookieMatch?.[1] ?? "");
5840
+ if (cookie) {
5841
+ headers.cookie = cookie;
5842
+ }
5843
+ return { url, headers };
5844
+ }
5845
+ function extractRawUrlSearchParam(url, key) {
5846
+ const search = url.search.startsWith("?") ? url.search.slice(1) : url.search;
5847
+ for (const part of search.split("&")) {
5848
+ const separatorIndex = part.indexOf("=");
5849
+ const name = separatorIndex >= 0 ? part.slice(0, separatorIndex) : part;
5850
+ if (decodeURIComponent(name.replace(/\+/g, " ")) === key) {
5851
+ const value = separatorIndex >= 0 ? part.slice(separatorIndex + 1) : "";
5852
+ return value.length > 0 ? value : null;
5853
+ }
5854
+ }
5855
+ return null;
5856
+ }
5857
+ function decodeRawSalesNavigatorQueryParam(rawQuery) {
5858
+ try {
5859
+ return decodeURIComponent(rawQuery.replace(/\+/g, "%20"));
5860
+ }
5861
+ catch {
5862
+ return rawQuery;
5863
+ }
5864
+ }
5865
+ function normalizeSalesNavigatorApiQueryValue(rawQuery) {
5866
+ const decoded = decodeRawSalesNavigatorQueryParam(rawQuery);
5867
+ const withRecentSearchId = decoded.replace(/recentSearchParam:\((?![^)]*\bid:)([^)]*)\)/, (_match, inner) => `recentSearchParam:(id:${Date.now()},${inner})`);
5868
+ return withRecentSearchId.includes("spellCorrectionEnabled:")
5869
+ ? withRecentSearchId
5870
+ : withRecentSearchId.replace(/^\(/, "(spellCorrectionEnabled:true,");
5871
+ }
5872
+ function buildSalesNavigatorApiRequestFromSearchUrl(searchUrl, config, count) {
5873
+ const source = new URL(searchUrl);
5874
+ if (source.pathname.includes("/sales-api/salesApiLeadSearch")) {
5875
+ return {
5876
+ url: source.toString(),
5877
+ headers: buildLinkedInSalesNavigatorLocalImportHeaders(config)
5878
+ };
5879
+ }
5880
+ if (!source.pathname.includes("/sales/search/people")) {
5881
+ throw new Error("Automatic Sales Navigator import requires a /sales/search/people URL.");
5882
+ }
5883
+ const rawQuery = extractRawUrlSearchParam(source, "query");
5884
+ if (!rawQuery) {
5885
+ throw new Error("Sales Navigator search URL is missing the query parameter.");
5886
+ }
5887
+ const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
5888
+ `${source.protocol}//${source.host}`;
5889
+ const rawSessionId = extractRawUrlSearchParam(source, "sessionId") ?? encodeURIComponent(generateLinkedInSessionId());
5890
+ const apiQuery = normalizeSalesNavigatorApiQueryValue(rawQuery);
5891
+ const normalizedCount = Math.max(1, Math.min(100, Math.trunc(count)));
5892
+ const apiUrl = `${baseUrl.replace(/\/+$/, "")}/sales-api/salesApiLeadSearch` +
5893
+ `?q=searchQuery&query=${apiQuery}&start=0&count=${normalizedCount}` +
5894
+ `&trackingParam=(sessionId:${rawSessionId})` +
5895
+ "&decorationId=com.linkedin.sales.deco.desktop.searchv2.LeadSearchResult-14";
5896
+ return {
5897
+ url: apiUrl,
5898
+ headers: buildLinkedInSalesNavigatorLocalImportHeaders(config)
5899
+ };
5900
+ }
5901
+ function withSalesNavigatorPaging(url, start, count) {
5902
+ const normalizedStart = String(Math.max(0, Math.trunc(start)));
5903
+ const normalizedCount = String(Math.max(1, Math.min(100, Math.trunc(count))));
5904
+ const hashIndex = url.indexOf("#");
5905
+ const withoutHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
5906
+ const hash = hashIndex >= 0 ? url.slice(hashIndex) : "";
5907
+ const queryIndex = withoutHash.indexOf("?");
5908
+ if (queryIndex < 0) {
5909
+ return `${withoutHash}?start=${normalizedStart}&count=${normalizedCount}${hash}`;
5910
+ }
5911
+ const base = withoutHash.slice(0, queryIndex);
5912
+ const search = withoutHash.slice(queryIndex + 1);
5913
+ const params = search
5914
+ .split("&")
5915
+ .filter((part) => {
5916
+ const key = part.split("=", 1)[0]?.toLowerCase();
5917
+ return key !== "start" && key !== "count";
5918
+ });
5919
+ params.push(`start=${normalizedStart}`, `count=${normalizedCount}`);
5920
+ return `${base}?${params.join("&")}${hash}`;
5921
+ }
5922
+ function buildLinkedInSalesNavigatorLocalImportHeaders(config) {
5923
+ return {
5924
+ accept: "*/*",
5925
+ "csrf-token": config.csrfToken,
5926
+ cookie: config.cookie,
5927
+ "user-agent": config.userAgent,
5928
+ "x-li-identity": config.identity,
5929
+ "x-restli-protocol-version": "2.0.0"
5930
+ };
5931
+ }
5932
+ function getNestedValue(record, pathParts) {
5933
+ let current = record;
5934
+ for (const part of pathParts) {
5935
+ if (typeof current !== "object" || current === null || Array.isArray(current)) {
5936
+ return undefined;
5937
+ }
5938
+ current = current[part];
5939
+ }
5940
+ return current;
5941
+ }
5942
+ function firstLocalString(record, paths) {
5943
+ for (const pathName of paths) {
5944
+ const value = getNestedValue(record, pathName.split("."));
5945
+ if (typeof value === "string" && value.trim().length > 0) {
5946
+ return value.trim();
5947
+ }
5948
+ }
5949
+ return null;
5950
+ }
5951
+ function flattenSalesNavigatorRecord(value) {
5952
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
5953
+ return {};
5954
+ }
5955
+ 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;
5958
+ }
5959
+ function deriveLocalSalesNavigatorProfileUrl(record) {
5960
+ const direct = firstLocalString(record, [
5961
+ "profileUrl",
5962
+ "defaultProfileUrl",
5963
+ "linkedInProfileUrl",
5964
+ "navigationUrl",
5965
+ "url",
5966
+ "profilePicture.profileUrl"
5967
+ ]);
5968
+ if (direct) {
5969
+ try {
5970
+ const parsed = new URL(direct, "https://www.linkedin.com");
5971
+ return parsed.toString();
5972
+ }
5973
+ catch {
5974
+ return direct;
5975
+ }
5976
+ }
5977
+ const publicIdentifier = firstLocalString(record, [
5978
+ "publicIdentifier",
5979
+ "public_id",
5980
+ "miniProfile.publicIdentifier",
5981
+ "member.publicIdentifier"
5982
+ ]);
5983
+ if (publicIdentifier) {
5984
+ return `https://www.linkedin.com/in/${encodeURIComponent(publicIdentifier)}`;
5985
+ }
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;
5989
+ }
5990
+ function firstLocalArrayRecord(record, key) {
5991
+ const value = record[key];
5992
+ if (!Array.isArray(value)) {
5993
+ return {};
5994
+ }
5995
+ const first = value.find((item) => typeof item === "object" && item !== null && !Array.isArray(item));
5996
+ return first ? first : {};
5997
+ }
5998
+ function firstLocalNestedRecord(record, key) {
5999
+ const value = record[key];
6000
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
6001
+ }
6002
+ function deriveLocalCompanyId(record, currentPosition, company) {
6003
+ const direct = firstLocalString(record, ["companyId", "currentCompanyId", "company.id"]);
6004
+ if (direct) {
6005
+ return direct;
6006
+ }
6007
+ const urn = firstLocalString(currentPosition, ["companyUrn"]) ?? firstLocalString(company, ["entityUrn"]);
6008
+ return urn?.match(/:(\d+)$/)?.[1] ?? null;
6009
+ }
6010
+ function formatSalesNavigatorTenure(value) {
6011
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
6012
+ return null;
6013
+ }
6014
+ 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;
6017
+ return [years, months].filter(Boolean).join(" ") || null;
6018
+ }
6019
+ function buildLinkedInImageUrl(value) {
6020
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
6021
+ return null;
6022
+ }
6023
+ const record = value;
6024
+ const rootUrl = typeof record.rootUrl === "string" ? record.rootUrl : null;
6025
+ if (!rootUrl || !Array.isArray(record.artifacts)) {
6026
+ return null;
6027
+ }
6028
+ const artifact = record.artifacts
6029
+ .filter((item) => typeof item === "object" && item !== null && !Array.isArray(item))
6030
+ .sort((left, right) => Number(right.width ?? 0) - Number(left.width ?? 0))[0];
6031
+ const pathSegment = typeof artifact?.fileIdentifyingUrlPathSegment === "string" ? artifact.fileIdentifyingUrlPathSegment : null;
6032
+ return pathSegment ? `${rootUrl}${pathSegment}` : null;
6033
+ }
6034
+ function deriveSharedConnectionsCount(record) {
6035
+ const badges = Array.isArray(record.spotlightBadges) ? record.spotlightBadges : [];
6036
+ for (const badge of badges) {
6037
+ if (typeof badge !== "object" || badge === null || Array.isArray(badge)) {
6038
+ continue;
6039
+ }
6040
+ const displayValue = firstLocalString(badge, ["displayValue"]);
6041
+ const match = displayValue?.match(/(\d+)\s+mutual connection/i);
6042
+ if (match) {
6043
+ return Number(match[1]);
6044
+ }
6045
+ }
6046
+ return null;
6047
+ }
6048
+ function deriveLocalSalesNavigatorFullName(record) {
6049
+ const direct = firstLocalString(record, [
6050
+ "fullName",
6051
+ "name",
6052
+ "title.text",
6053
+ "profileName",
6054
+ "miniProfile.fullName"
6055
+ ]);
6056
+ if (direct) {
6057
+ return direct;
6058
+ }
6059
+ const firstName = firstLocalString(record, ["firstName", "miniProfile.firstName"]);
6060
+ const lastName = firstLocalString(record, ["lastName", "miniProfile.lastName"]);
6061
+ return [firstName, lastName].filter(Boolean).join(" ").trim() || null;
6062
+ }
6063
+ function collectPotentialSalesNavigatorRows(value, rows = []) {
6064
+ if (Array.isArray(value)) {
6065
+ for (const item of value) {
6066
+ collectPotentialSalesNavigatorRows(item, rows);
6067
+ }
6068
+ return rows;
6069
+ }
6070
+ if (typeof value !== "object" || value === null) {
6071
+ return rows;
6072
+ }
6073
+ const record = flattenSalesNavigatorRecord(value);
6074
+ if (deriveLocalSalesNavigatorProfileUrl(record) &&
6075
+ (deriveLocalSalesNavigatorFullName(record) ||
6076
+ firstLocalString(record, ["occupation", "title", "headline", "currentPositions.0.title"]))) {
6077
+ rows.push(record);
6078
+ }
6079
+ for (const nested of Object.values(value)) {
6080
+ if (typeof nested === "object" && nested !== null) {
6081
+ collectPotentialSalesNavigatorRows(nested, rows);
6082
+ }
6083
+ }
6084
+ return rows;
6085
+ }
6086
+ function normalizeLocalSalesNavigatorPeople(responseBody, queryUrl) {
6087
+ const seen = new Set();
6088
+ const rows = [];
6089
+ for (const record of collectPotentialSalesNavigatorRows(responseBody)) {
6090
+ const currentPosition = firstLocalArrayRecord(record, "currentPositions");
6091
+ const company = firstLocalNestedRecord(currentPosition, "companyUrnResolutionResult");
6092
+ const profileUrl = deriveLocalSalesNavigatorProfileUrl(record);
6093
+ if (!profileUrl || seen.has(profileUrl)) {
6094
+ continue;
6095
+ }
6096
+ seen.add(profileUrl);
6097
+ const fullName = deriveLocalSalesNavigatorFullName(record);
6098
+ const companyId = deriveLocalCompanyId(record, currentPosition, company);
6099
+ rows.push({
6100
+ profileUrl,
6101
+ defaultProfileUrl: profileUrl,
6102
+ linkedInProfileUrl: profileUrl.includes("/in/") ? profileUrl : null,
6103
+ fullName,
6104
+ firstName: firstLocalString(record, ["firstName", "miniProfile.firstName"]),
6105
+ 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"]),
6111
+ 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"]),
6116
+ 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),
6122
+ sharedConnectionsCount: deriveSharedConnectionsCount(record),
6123
+ profileImageUrl: firstLocalString(record, ["profileImageUrl", "pictureUrl", "profilePicture.displayImage"]) ?? buildLinkedInImageUrl(record.profilePictureDisplayImage),
6124
+ vmid: firstLocalString(record, ["vmid", "memberId", "profileId"]),
6125
+ isPremium: typeof record.premium === "boolean" ? record.premium : null,
6126
+ isOpenLink: typeof record.openLink === "boolean" ? record.openLink : null,
6127
+ query: queryUrl,
6128
+ timestamp: new Date().toISOString(),
6129
+ rawLocalSalesNavigatorResult: record
6130
+ });
6131
+ }
6132
+ return rows;
6133
+ }
6134
+ function extractLocalSalesNavigatorTotalResults(responseBody) {
6135
+ if (typeof responseBody !== "object" || responseBody === null || Array.isArray(responseBody)) {
6136
+ return null;
6137
+ }
6138
+ const record = responseBody;
6139
+ const candidates = [record.paging, record.metadata, record.searchResultsMetadata, record];
6140
+ for (const candidate of candidates) {
6141
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
6142
+ continue;
6143
+ }
6144
+ for (const key of ["total", "totalResults", "resultCount", "count"]) {
6145
+ const value = candidate[key];
6146
+ if (typeof value === "number" && Number.isFinite(value)) {
6147
+ return Math.trunc(value);
6148
+ }
6149
+ }
6150
+ }
6151
+ return null;
6152
+ }
6153
+ function randomIntegerBetween(min, max) {
6154
+ const lower = Math.max(0, Math.trunc(min));
6155
+ const upper = Math.max(lower, Math.trunc(max));
6156
+ return lower + Math.floor(Math.random() * (upper - lower + 1));
6157
+ }
6158
+ function isSalesNavigatorRateLimitStatus(status, text) {
6159
+ return (status === 429 ||
6160
+ status === 999 ||
6161
+ /too many requests|rate limit|unusual activity|automated activity|temporarily restricted/i.test(text));
6162
+ }
6163
+ function isRetryableLocalSalesNavigatorStatus(status) {
6164
+ return status === 408 || status === 425 || status === 500 || status === 502 || status === 503 || status === 504;
6165
+ }
6166
+ async function waitWithFullJitter(baseDelayMs, maxDelayMs, attempt) {
6167
+ const exponentialCap = Math.min(Math.max(0, Math.trunc(maxDelayMs)), Math.max(0, Math.trunc(baseDelayMs)) * 2 ** Math.max(0, attempt));
6168
+ const delayMs = randomIntegerBetween(0, exponentialCap);
6169
+ if (delayMs > 0) {
6170
+ await delay(delayMs);
6171
+ }
6172
+ return delayMs;
6173
+ }
6174
+ async function fetchLocalSalesNavigatorRequest(parsedRequest, retryOptions) {
6175
+ let retryCount = 0;
6176
+ let retryDelayMs = 0;
6177
+ for (let attempt = 0;; attempt += 1) {
6178
+ try {
6179
+ const response = await fetch(parsedRequest.url, {
6180
+ method: "GET",
6181
+ headers: parsedRequest.headers
6182
+ });
6183
+ const text = await response.text();
6184
+ if (!response.ok) {
6185
+ const preview = text.slice(0, 300);
6186
+ if (isSalesNavigatorRateLimitStatus(response.status, text)) {
6187
+ throw new LocalSalesNavigatorRateLimitError(response.status, preview);
6188
+ }
6189
+ if (isRetryableLocalSalesNavigatorStatus(response.status) && attempt < retryOptions.maxRetries) {
6190
+ retryCount += 1;
6191
+ retryDelayMs += await waitWithFullJitter(retryOptions.retryBaseDelayMs, retryOptions.retryMaxDelayMs, attempt);
6192
+ continue;
6193
+ }
6194
+ throw new Error(`Sales Navigator request failed with HTTP ${response.status}: ${preview}`);
6195
+ }
6196
+ try {
6197
+ return { body: JSON.parse(text), retryCount, retryDelayMs };
6198
+ }
6199
+ catch {
6200
+ throw new Error("Sales Navigator response was not valid JSON.");
6201
+ }
6202
+ }
6203
+ catch (error) {
6204
+ if (error instanceof LocalSalesNavigatorRateLimitError) {
6205
+ throw error;
6206
+ }
6207
+ if (attempt < retryOptions.maxRetries) {
6208
+ retryCount += 1;
6209
+ retryDelayMs += await waitWithFullJitter(retryOptions.retryBaseDelayMs, retryOptions.retryMaxDelayMs, attempt);
6210
+ continue;
6211
+ }
6212
+ throw error;
6213
+ }
6214
+ }
6215
+ }
6216
+ async function fetchAllLocalSalesNavigatorPeople(parsedRequest, options) {
6217
+ const people = [];
6218
+ const seen = new Set();
6219
+ let totalResults = null;
6220
+ let fetchedPages = 0;
6221
+ let totalDelayMs = 0;
6222
+ let retryCount = 0;
6223
+ 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));
6225
+ const pageDelayMinMs = Math.max(0, Math.trunc(options.pageDelayMinMs));
6226
+ const pageDelayMaxMs = Math.max(pageDelayMinMs, Math.trunc(options.pageDelayMaxMs));
6227
+ for (let start = 0;; start += pageSize) {
6228
+ const remainingRequested = requestedProfiles == null ? pageSize : requestedProfiles - people.length;
6229
+ if (remainingRequested <= 0) {
6230
+ break;
6231
+ }
6232
+ const currentCount = Math.min(pageSize, remainingRequested);
6233
+ const pageRequest = {
6234
+ ...parsedRequest,
6235
+ url: withSalesNavigatorPaging(parsedRequest.url, start, currentCount)
6236
+ };
6237
+ const response = await fetchLocalSalesNavigatorRequest(pageRequest, options.retry);
6238
+ retryCount += response.retryCount;
6239
+ totalDelayMs += response.retryDelayMs;
6240
+ const responseBody = response.body;
6241
+ fetchedPages += 1;
6242
+ totalResults = totalResults ?? extractLocalSalesNavigatorTotalResults(responseBody);
6243
+ const pagePeople = normalizeLocalSalesNavigatorPeople(responseBody, pageRequest.url);
6244
+ let added = 0;
6245
+ for (const person of pagePeople) {
6246
+ if (seen.has(person.profileUrl)) {
6247
+ continue;
6248
+ }
6249
+ seen.add(person.profileUrl);
6250
+ people.push(person);
6251
+ added += 1;
6252
+ if (requestedProfiles != null && people.length >= requestedProfiles) {
6253
+ break;
6254
+ }
6255
+ }
6256
+ const reportedTotalReached = totalResults != null && start + currentCount >= totalResults;
6257
+ if (pagePeople.length === 0 || added === 0 || reportedTotalReached) {
6258
+ break;
6259
+ }
6260
+ const pageDelayMs = randomIntegerBetween(pageDelayMinMs, pageDelayMaxMs);
6261
+ if (pageDelayMs > 0) {
6262
+ totalDelayMs += pageDelayMs;
6263
+ await delay(pageDelayMs);
6264
+ }
6265
+ }
6266
+ return {
6267
+ people,
6268
+ totalResults,
6269
+ fetchedPages,
6270
+ sourceQueryUrl: parsedRequest.url,
6271
+ pacing: {
6272
+ delayMinMs: pageDelayMinMs,
6273
+ delayMaxMs: pageDelayMaxMs,
6274
+ 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}`
6285
+ },
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;
6305
+ }
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}`
6321
+ }
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`, {
5727
6327
  method: "POST",
5728
6328
  headers: {
5729
6329
  "Content-Type": "application/json",
5730
6330
  Authorization: `Bearer ${currentSession.accessToken}`
5731
6331
  },
5732
6332
  body: JSON.stringify(payload)
5733
- }), PhantombusterContainersSyncResponseSchema);
6333
+ }), AffiliateCampaignLaunchResponseSchema);
5734
6334
  return value;
5735
6335
  }
5736
6336
  async function runLeadPoolEmailFinderViaApp(session, payload) {
5737
- const url = new URL("/api/leadpool/process-hunter-emails", session.apiBaseUrl);
5738
- url.searchParams.set("provider", payload.provider);
5739
- url.searchParams.set("limit", String(payload.limit));
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));
5740
6340
  if (payload.concurrency != null) {
5741
- url.searchParams.set("concurrency", String(payload.concurrency));
6341
+ url.searchParams.set('concurrency', String(payload.concurrency));
5742
6342
  }
5743
6343
  if (payload.retryNotFoundAfterDays != null) {
5744
- url.searchParams.set("retryNotFoundAfterDays", String(payload.retryNotFoundAfterDays));
6344
+ url.searchParams.set('retryNotFoundAfterDays', String(payload.retryNotFoundAfterDays));
5745
6345
  }
5746
6346
  const { value } = await fetchCliJson(session, (currentSession) => fetch(url.toString(), {
5747
6347
  method: "POST",
@@ -5751,6 +6351,17 @@ async function runLeadPoolEmailFinderViaApp(session, payload) {
5751
6351
  }), LeadPoolEmailFinderProcessResponseSchema);
5752
6352
  return value;
5753
6353
  }
6354
+ async function syncPhantombusterContainersViaApp(session, payload) {
6355
+ const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/phantombuster/containers/sync`, {
6356
+ method: "POST",
6357
+ headers: {
6358
+ "Content-Type": "application/json",
6359
+ Authorization: `Bearer ${currentSession.accessToken}`
6360
+ },
6361
+ body: JSON.stringify(payload)
6362
+ }), PhantombusterContainersSyncResponseSchema);
6363
+ return value;
6364
+ }
5754
6365
  function serializeSalesNavigatorFiltersForApi(filters) {
5755
6366
  return filters.map((filter) => ({
5756
6367
  type: filter.type,
@@ -5909,7 +6520,8 @@ async function startSalesNavigatorExport(session, payload, traceId) {
5909
6520
  const sessionOverridePayload = exportSession
5910
6521
  ? {
5911
6522
  sessionCookie: exportSession.sessionCookie,
5912
- selectedSessionCookieSha256: exportSession.selectedSessionCookieSha256
6523
+ selectedSessionCookieSha256: exportSession.selectedSessionCookieSha256,
6524
+ selectedSessionUserAgent: exportSession.selectedSessionUserAgent
5913
6525
  }
5914
6526
  : {};
5915
6527
  return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/export`, {
@@ -6738,9 +7350,6 @@ function hashSalesNavigatorSessionCookieForPhantombuster(sessionCookie) {
6738
7350
  }
6739
7351
  return createHash("sha256").update(liAt).digest("hex");
6740
7352
  }
6741
- function normalizeSalesNavigatorSessionCookieForPhantombuster(sessionCookie) {
6742
- return extractLiAtCookieValue(sessionCookie) ?? sessionCookie;
6743
- }
6744
7353
  function cacheSalesNavigatorExportSessionOverride(queryUrl, value) {
6745
7354
  cachedSalesNavigatorExportSessionOverride = {
6746
7355
  queryUrl,
@@ -6760,8 +7369,9 @@ async function resolveSalesNavigatorExportSessionOverride(queryUrl, options) {
6760
7369
  const selectedSessionCookieSha256 = hashSalesNavigatorSessionCookieForPhantombuster(localExtensionConfig.cookie);
6761
7370
  if (process.env.SALESPROMPTER_SALESNAV_EXPORT_SKIP_EXTENSION_PREFLIGHT === "1") {
6762
7371
  return cacheSalesNavigatorExportSessionOverride(queryUrl, {
6763
- sessionCookie: normalizeSalesNavigatorSessionCookieForPhantombuster(localExtensionConfig.cookie),
7372
+ sessionCookie: localExtensionConfig.cookie,
6764
7373
  selectedSessionCookieSha256,
7374
+ selectedSessionUserAgent: localExtensionConfig.userAgent,
6765
7375
  source: "chrome_extension"
6766
7376
  });
6767
7377
  }
@@ -6778,8 +7388,9 @@ async function resolveSalesNavigatorExportSessionOverride(queryUrl, options) {
6778
7388
  });
6779
7389
  if (probe.status === "ok") {
6780
7390
  return cacheSalesNavigatorExportSessionOverride(queryUrl, {
6781
- sessionCookie: normalizeSalesNavigatorSessionCookieForPhantombuster(localExtensionConfig.cookie),
7391
+ sessionCookie: localExtensionConfig.cookie,
6782
7392
  selectedSessionCookieSha256,
7393
+ selectedSessionUserAgent: localExtensionConfig.userAgent,
6783
7394
  source: "chrome_extension"
6784
7395
  });
6785
7396
  }
@@ -6791,9 +7402,10 @@ async function resolveSalesNavigatorExportSessionOverride(queryUrl, options) {
6791
7402
  });
6792
7403
  if (claimed?.sessionCookie) {
6793
7404
  return cacheSalesNavigatorExportSessionOverride(queryUrl, {
6794
- sessionCookie: normalizeSalesNavigatorSessionCookieForPhantombuster(claimed.sessionCookie),
7405
+ sessionCookie: claimed.sessionCookie,
6795
7406
  selectedSessionCookieSha256: claimed.sessionCookieSha256 ??
6796
7407
  hashSalesNavigatorSessionCookieForPhantombuster(claimed.sessionCookie),
7408
+ selectedSessionUserAgent: null,
6797
7409
  source: "session_vault"
6798
7410
  });
6799
7411
  }
@@ -9172,7 +9784,7 @@ program
9172
9784
  const titleLimit = options.titleLimit === undefined
9173
9785
  ? undefined
9174
9786
  : z.coerce.number().int().min(1).max(1000).parse(options.titleLimit);
9175
- const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
9787
+ const maxResultsPerSearch = z.coerce.number().int().min(1).max(100000).parse(options.maxResultsPerSearch);
9176
9788
  const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
9177
9789
  const maxSplitDepth = z.coerce.number().int().min(1).max(6).parse(options.maxSplitDepth);
9178
9790
  const maxSlicesPerTitle = z.coerce.number().int().min(1).max(10000).parse(options.maxSlicesPerTitle);
@@ -10013,6 +10625,7 @@ program
10013
10625
  .alias("pb:containers:sync")
10014
10626
  .description("Fetch Phantombuster containers for configured agents and store them in Neon.")
10015
10627
  .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, [])
10016
10629
  .option("--limit <number>", "Maximum containers to fetch per Phantombuster page", "100")
10017
10630
  .option("--max-pages <number>", "Maximum Phantombuster pages to fetch per agent", "50")
10018
10631
  .option("--mode <mode>", "Phantombuster container mode: all or finalized", "all")
@@ -10022,6 +10635,7 @@ program
10022
10635
  .option("--out <path>", "Optional local JSON output path")
10023
10636
  .action(async (options) => {
10024
10637
  const agentIds = z.array(z.string().min(1)).parse(options.agentId);
10638
+ const containerIds = z.array(z.string().min(1)).parse(options.containerId);
10025
10639
  const limit = z.coerce.number().int().min(1).max(500).parse(options.limit);
10026
10640
  const maxPages = z.coerce.number().int().min(1).max(500).parse(options.maxPages);
10027
10641
  const mode = z.enum(["all", "finalized"]).parse(options.mode);
@@ -10031,6 +10645,7 @@ program
10031
10645
  const session = await requireAuthSession();
10032
10646
  const result = await syncPhantombusterContainersViaApp(session, {
10033
10647
  agentIds: agentIds.length > 0 ? agentIds : undefined,
10648
+ containerIds: containerIds.length > 0 ? containerIds : undefined,
10034
10649
  limit,
10035
10650
  maxPages,
10036
10651
  mode,
@@ -10122,6 +10737,100 @@ program
10122
10737
  }
10123
10738
  printOutput(payload);
10124
10739
  });
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
+ });
10125
10834
  program
10126
10835
  .command("salesnav:count")
10127
10836
  .alias("search:count")
@@ -10784,6 +11493,54 @@ program
10784
11493
  execution
10785
11494
  });
10786
11495
  });
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
+ });
10787
11544
  program
10788
11545
  .command("contacts:process-emails")
10789
11546
  .alias("contacts:resolve-emails")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "description": "Sales workflow CLI for guided lead generation, enrichment, scoring, and sync.",
5
5
  "author": "Daniel Sinewe <hello@danielsinewe.com>",
6
6
  "type": "module",