salesprompter-cli 0.1.23 → 0.1.24

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
@@ -15,12 +15,13 @@ import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRo
15
15
  import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
16
16
  import { auditDomainDecisions, buildDomainfinderBacklogQueries, buildDomainfinderCandidatesSql, buildDomainfinderInputSql, buildDomainfinderWritebackSql, buildExistingDomainRepairSql, buildExistingDomainAuditQueries, compareDomainSelectionStrategies, selectBestDomains } from "./domainfinder.js";
17
17
  import { buildDeelSalesNavCsvHeader, buildDeelSalesNavCsvLines, isDeelRelevantSalesNavTitle, normalizeDeelSalesNavRow } from "./deel-salesnav.js";
18
- import { buildDeelOutreachExportSql, buildDeelOutreachPack, normalizeDeelOutreachRows } from "./deel-outreach.js";
18
+ import { buildDeelLeadPoolContactSql, buildDeelOutreachExportSql, buildDeelOutreachPack, normalizeDeelOutreachRows } from "./deel-outreach.js";
19
19
  import { buildDirectPathLeadExportSql, normalizeDirectPathRows, segmentDirectPathRows } from "./direct-path.js";
20
20
  import { AccountLeadProvider, DryRunSyncProvider, HeuristicCompanyProvider, HeuristicEnrichmentProvider, HeuristicPeopleSearchProvider, HeuristicScoringProvider, RoutedSyncProvider } from "./engine.js";
21
21
  import { analyzeHistoricalQueries } from "./historical-queries.js";
22
22
  import { buildHistoricalVendorIcp, buildVendorIcp } from "./icp-templates.js";
23
23
  import { InstantlySyncProvider } from "./instantly.js";
24
+ import { backfillLinkedInCompanies } from "./linkedin-companies.js";
24
25
  import { crawlLinkedInProductCategory } from "./linkedin-products.js";
25
26
  import { claimValidatedSalesNavigatorSessionCookieForCli } from "./linkedin-session.js";
26
27
  import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
@@ -56,6 +57,27 @@ const LinkedInProductIngestResponseSchema = z.object({
56
57
  upserted: z.number().int().nonnegative(),
57
58
  totalInCatalog: z.number().int().nonnegative().optional()
58
59
  });
60
+ const LinkedInCompanyBackfillLaunchResponseSchema = z.object({
61
+ clientId: z.number().int().positive(),
62
+ launched: z.boolean(),
63
+ agentId: z.string().min(1),
64
+ webhookUrl: z.string().url(),
65
+ inputUrl: z.string().url().nullable(),
66
+ containerId: z.string().min(1).nullable(),
67
+ candidates: z.array(z.object({
68
+ companyId: z.number().int().positive(),
69
+ companyUrl: z.string().url(),
70
+ companyName: z.string().nullable().optional(),
71
+ companyFilter: z.string().nullable().optional()
72
+ }))
73
+ });
74
+ const LinkedInCompanyBackfillStatusResponseSchema = z.object({
75
+ status: z.literal("ok"),
76
+ containerId: z.string().min(1),
77
+ running: z.boolean(),
78
+ processed: z.boolean(),
79
+ remaining: z.number().int().nonnegative()
80
+ });
59
81
  const SalesNavigatorLaunchDiagnosticsSchema = z.object({
60
82
  orderedCandidateAgentIds: z.array(z.string().min(1)),
61
83
  runningAgentIds: z.array(z.string().min(1)),
@@ -214,6 +236,40 @@ const SalesNavigatorCrawlReportResponseSchema = z.object({
214
236
  status: z.literal("ok"),
215
237
  job: SalesNavigatorCrawlJobSummarySchema
216
238
  });
239
+ const helpAliasByCommandName = new Map([
240
+ ["contacts:find-linkedin-urls", "contacts:resolve-profiles"],
241
+ ["linkedin-companies:backfill", "companies:enrich"],
242
+ ["linkedin-products:scrape", "market:scrape"],
243
+ ["salesnav:from-product-category", "leads:discover"],
244
+ ["salesnav:crawl", "search:run"],
245
+ ["salesnav:crawl:status", "search:status"],
246
+ ["salesnav:export", "search:export"],
247
+ ["salesnav:count", "search:count"]
248
+ ]);
249
+ const helpVisibleCommandNames = new Set([
250
+ "auth:login",
251
+ "wizard",
252
+ "auth:whoami",
253
+ "llm:ready",
254
+ "contacts:find-linkedin-urls",
255
+ "auth:logout",
256
+ "account:resolve",
257
+ "icp:define",
258
+ "icp:vendor",
259
+ "leads:generate",
260
+ "leads:enrich",
261
+ "leads:score",
262
+ "leads:pipeline",
263
+ "sync:crm",
264
+ "sync:outreach",
265
+ "linkedin-companies:backfill",
266
+ "linkedin-products:scrape",
267
+ "salesnav:from-product-category",
268
+ "salesnav:crawl",
269
+ "salesnav:crawl:status",
270
+ "salesnav:export",
271
+ "salesnav:count"
272
+ ]);
217
273
  function printOutput(value) {
218
274
  if (runtimeOutputOptions.quiet) {
219
275
  return;
@@ -227,6 +283,13 @@ function writeProgress(message) {
227
283
  }
228
284
  process.stderr.write(`${message}\n`);
229
285
  }
286
+ function formatHelpArgumentTerm(argument) {
287
+ const term = argument.name();
288
+ if (argument.variadic) {
289
+ return argument.required ? `<${term}...>` : `[${term}...]`;
290
+ }
291
+ return argument.required ? `<${term}>` : `[${term}]`;
292
+ }
230
293
  function applyGlobalOutputOptions(actionCommand) {
231
294
  const globalOptions = actionCommand.optsWithGlobals();
232
295
  runtimeOutputOptions.json = Boolean(globalOptions.json);
@@ -331,6 +394,27 @@ function canPromptForInteractiveLogin() {
331
394
  }
332
395
  return Boolean(process.stdin.isTTY && process.stderr.isTTY);
333
396
  }
397
+ function resolveNonInteractiveAuthToken(env = process.env) {
398
+ const token = env.SALESPROMPTER_TOKEN?.trim() ||
399
+ env.SALESPROMPTER_API_TOKEN?.trim() ||
400
+ env.SALESPROMPTER_AUTH_TOKEN?.trim() ||
401
+ "";
402
+ return token.length > 0 ? token : null;
403
+ }
404
+ async function readAllStdin() {
405
+ if (process.stdin.isTTY) {
406
+ return "";
407
+ }
408
+ return await new Promise((resolve, reject) => {
409
+ let data = "";
410
+ process.stdin.setEncoding("utf8");
411
+ process.stdin.on("data", (chunk) => {
412
+ data += chunk;
413
+ });
414
+ process.stdin.on("end", () => resolve(data));
415
+ process.stdin.on("error", reject);
416
+ });
417
+ }
334
418
  async function ensureInteractiveAuthSession(apiUrl) {
335
419
  if (!canPromptForInteractiveLogin()) {
336
420
  return;
@@ -347,6 +431,523 @@ function shellQuote(value) {
347
431
  }
348
432
  return `'${value.replaceAll("'", "'\\''")}'`;
349
433
  }
434
+ function splitLooseDelimitedLine(line, delimiter) {
435
+ if (delimiter !== ",") {
436
+ return line.split(delimiter);
437
+ }
438
+ const values = [];
439
+ let current = "";
440
+ let inQuotes = false;
441
+ for (let index = 0; index < line.length; index += 1) {
442
+ const character = line[index];
443
+ const nextCharacter = line[index + 1];
444
+ if (character === '"') {
445
+ if (inQuotes && nextCharacter === '"') {
446
+ current += '"';
447
+ index += 1;
448
+ continue;
449
+ }
450
+ inQuotes = !inQuotes;
451
+ continue;
452
+ }
453
+ if (character === "," && !inQuotes) {
454
+ values.push(current);
455
+ current = "";
456
+ continue;
457
+ }
458
+ current += character;
459
+ }
460
+ values.push(current);
461
+ return values;
462
+ }
463
+ function detectLooseDelimiter(sampleLine) {
464
+ if (sampleLine.includes("\t")) {
465
+ return "\t";
466
+ }
467
+ if (sampleLine.includes(";")) {
468
+ return ";";
469
+ }
470
+ return ",";
471
+ }
472
+ function normalizeLooseMatchText(value) {
473
+ return String(value ?? "")
474
+ .normalize("NFKD")
475
+ .replace(/[\u0300-\u036f]/g, "")
476
+ .replace(/ß/g, "ss")
477
+ .toLowerCase()
478
+ .replace(/&/g, " and ")
479
+ .replace(/[^a-z0-9]+/g, " ")
480
+ .replace(/\b(gmbh|mbh|co|kg|ohg|ag|ltd|limited|ges|gesellschaft|m|b|h|und|and)\b/g, " ")
481
+ .replace(/\s+/g, " ")
482
+ .trim();
483
+ }
484
+ function normalizeLookupWhitespace(value) {
485
+ return String(value ?? "")
486
+ .replace(/[\u2010-\u2015]/g, "-")
487
+ .replace(/\s+/g, " ")
488
+ .trim();
489
+ }
490
+ function stripLookupHonorifics(value) {
491
+ return value.replace(/^(dr\.?|dipl\.-?ing\.?|prof\.?|ing\.?|mag\.?)\s+/i, "").trim();
492
+ }
493
+ function normalizeLookupCompanyForSearch(value) {
494
+ return normalizeLookupWhitespace(value)
495
+ .replace(/[+]/g, " ")
496
+ .replace(/\s*-\s*/g, " ")
497
+ .replace(/[.,]/g, " ")
498
+ .replace(/\s+/g, " ")
499
+ .trim();
500
+ }
501
+ function splitLookupFullName(fullName) {
502
+ const parts = normalizeLookupWhitespace(fullName).split(" ").filter(Boolean);
503
+ return {
504
+ firstName: parts[0] ?? "",
505
+ lastName: parts.slice(1).join(" ")
506
+ };
507
+ }
508
+ function buildSyntheticLookupEmail(contactId) {
509
+ return `linkedin-lookup+${contactId}@salesprompter.invalid`;
510
+ }
511
+ function looksLikeLookupCompanyRow(fullName, companyName) {
512
+ const fullNameComparable = normalizeLooseMatchText(fullName);
513
+ const companyComparable = normalizeLooseMatchText(companyName);
514
+ if (!fullNameComparable || !companyComparable) {
515
+ return false;
516
+ }
517
+ return (fullNameComparable === companyComparable ||
518
+ fullNameComparable.includes(companyComparable) ||
519
+ companyComparable.includes(fullNameComparable));
520
+ }
521
+ function parseLinkedInUrlLookupInput(content) {
522
+ const trimmed = content.trim();
523
+ if (!trimmed) {
524
+ return [];
525
+ }
526
+ if (trimmed.startsWith("[")) {
527
+ const parsed = z
528
+ .array(z.object({
529
+ clientId: z.union([z.string(), z.number()]).nullish(),
530
+ fullName: z.string().nullish(),
531
+ companyName: z.string().nullish(),
532
+ email: z.string().nullish(),
533
+ jobTitle: z.string().nullish()
534
+ }))
535
+ .parse(JSON.parse(trimmed));
536
+ return parsed
537
+ .map((row) => ({
538
+ clientId: row.clientId == null ? null : String(row.clientId).trim() || null,
539
+ fullName: row.fullName?.trim() ?? "",
540
+ companyName: row.companyName?.trim() ?? "",
541
+ email: row.email?.trim() || undefined,
542
+ jobTitle: row.jobTitle?.trim() || undefined
543
+ }))
544
+ .filter((row) => row.fullName.length > 0 || row.companyName.length > 0);
545
+ }
546
+ const lines = trimmed
547
+ .split(/\r?\n/)
548
+ .map((line) => line.trim())
549
+ .filter((line) => line.length > 0);
550
+ if (lines.length === 0) {
551
+ return [];
552
+ }
553
+ const delimiter = detectLooseDelimiter(lines[0] ?? "");
554
+ const headerValues = splitLooseDelimitedLine(lines[0] ?? "", delimiter).map((value) => value.trim().toLowerCase());
555
+ const hasHeader = headerValues.includes("fullname") ||
556
+ headerValues.includes("full_name") ||
557
+ headerValues.includes("companyname") ||
558
+ headerValues.includes("company_name");
559
+ const dataLines = hasHeader ? lines.slice(1) : lines;
560
+ const clientIdIndex = hasHeader
561
+ ? headerValues.findIndex((value) => ["clientid", "client_id"].includes(value))
562
+ : 0;
563
+ const fullNameIndex = hasHeader
564
+ ? headerValues.findIndex((value) => ["fullname", "full_name", "contact_name", "name"].includes(value))
565
+ : 1;
566
+ const companyNameIndex = hasHeader
567
+ ? headerValues.findIndex((value) => ["companyname", "company_name"].includes(value))
568
+ : 2;
569
+ const emailIndex = hasHeader ? headerValues.findIndex((value) => value === "email") : -1;
570
+ const jobTitleIndex = hasHeader
571
+ ? headerValues.findIndex((value) => ["jobtitle", "job_title", "title"].includes(value))
572
+ : -1;
573
+ return dataLines
574
+ .map((line) => splitLooseDelimitedLine(line, delimiter).map((value) => value.trim()))
575
+ .map((columns) => ({
576
+ clientId: clientIdIndex >= 0 ? columns[clientIdIndex] || null : null,
577
+ fullName: fullNameIndex >= 0 ? columns[fullNameIndex] || "" : "",
578
+ companyName: companyNameIndex >= 0 ? columns[companyNameIndex] || "" : "",
579
+ email: emailIndex >= 0 ? columns[emailIndex] || undefined : undefined,
580
+ jobTitle: jobTitleIndex >= 0 ? columns[jobTitleIndex] || undefined : undefined
581
+ }))
582
+ .filter((row) => row.fullName.length > 0 || row.companyName.length > 0);
583
+ }
584
+ function toLinkedInUrlLookupContacts(rows) {
585
+ return rows.flatMap((row, index) => {
586
+ const contactId = String(index + 1);
587
+ const syntheticEmail = row.email?.trim() || buildSyntheticLookupEmail(contactId);
588
+ const rawCompanyName = normalizeLookupWhitespace(row.companyName);
589
+ const cleanedCompanyName = normalizeLookupCompanyForSearch(rawCompanyName);
590
+ const rawFullName = normalizeLookupWhitespace(row.fullName);
591
+ if (looksLikeLookupCompanyRow(rawFullName, rawCompanyName)) {
592
+ return [
593
+ {
594
+ contact_id: contactId,
595
+ firstName: "",
596
+ lastName: "",
597
+ companyName: cleanedCompanyName,
598
+ companyNameOriginal: rawCompanyName || undefined,
599
+ email: syntheticEmail,
600
+ jobTitle: row.jobTitle
601
+ }
602
+ ];
603
+ }
604
+ const cleanedFullName = stripLookupHonorifics(rawFullName);
605
+ const rawSplit = splitLookupFullName(rawFullName);
606
+ const cleanedSplit = splitLookupFullName(cleanedFullName);
607
+ const contacts = [
608
+ {
609
+ contact_id: contactId,
610
+ firstName: cleanedSplit.firstName,
611
+ lastName: cleanedSplit.lastName,
612
+ companyName: cleanedCompanyName,
613
+ companyNameOriginal: rawCompanyName || undefined,
614
+ email: syntheticEmail,
615
+ jobTitle: row.jobTitle
616
+ }
617
+ ];
618
+ const rawDiffers = rawSplit.firstName !== cleanedSplit.firstName ||
619
+ rawSplit.lastName !== cleanedSplit.lastName;
620
+ if (rawDiffers && (rawSplit.firstName || rawSplit.lastName)) {
621
+ contacts.push({
622
+ contact_id: contactId,
623
+ firstName: rawSplit.firstName,
624
+ lastName: rawSplit.lastName,
625
+ companyName: cleanedCompanyName,
626
+ companyNameOriginal: rawCompanyName || undefined,
627
+ email: syntheticEmail,
628
+ jobTitle: row.jobTitle,
629
+ isVariation: true
630
+ });
631
+ }
632
+ return contacts;
633
+ });
634
+ }
635
+ function readPipedreamLinkedInEnrichmentConfig() {
636
+ const endpointUrl = process.env.SALESPROMPTER_LINKEDIN_ENRICHMENT_ENDPOINT_URL?.trim() ||
637
+ (process.env.PIPEDREAM_ENDPOINT_ID?.trim()
638
+ ? `https://${process.env.PIPEDREAM_ENDPOINT_ID.trim()}.m.pipedream.net`
639
+ : "");
640
+ if (!endpointUrl) {
641
+ throw new Error("Missing LinkedIn enrichment endpoint. Set SALESPROMPTER_LINKEDIN_ENRICHMENT_ENDPOINT_URL or PIPEDREAM_ENDPOINT_ID.");
642
+ }
643
+ return {
644
+ endpointUrl,
645
+ secret: process.env.PIPEDREAM_SECRET_KEY?.trim() || "",
646
+ clientId: process.env.PIPEDREAM_CLIENT_ID?.trim() || "",
647
+ projectId: process.env.PIPEDREAM_PROJECT_ID?.trim() || "",
648
+ projectEnvironment: process.env.PIPEDREAM_PROJECT_ENVIRONMENT?.trim() || ""
649
+ };
650
+ }
651
+ function deriveCsrfTokenFromCookie(cookie) {
652
+ const match = cookie.match(/JSESSIONID="?([^";]+)"?/i);
653
+ return match?.[1]?.trim() || "";
654
+ }
655
+ function readLinkedInDirectLookupConfig() {
656
+ const csrfToken = process.env.SALESPROMPTER_LINKEDIN_CSRF_TOKEN?.trim() ||
657
+ process.env.LINKEDIN_CSRF_TOKEN?.trim() ||
658
+ deriveCsrfTokenFromCookie(process.env.SALESPROMPTER_LINKEDIN_SALES_NAV_COOKIE?.trim() ||
659
+ process.env.LINKEDIN_SALES_NAV_COOKIE?.trim() ||
660
+ "");
661
+ const identity = process.env.SALESPROMPTER_LINKEDIN_X_LI_IDENTITY?.trim() ||
662
+ process.env.LINKEDIN_X_LI_IDENTITY?.trim() ||
663
+ "";
664
+ const cookie = process.env.SALESPROMPTER_LINKEDIN_SALES_NAV_COOKIE?.trim() ||
665
+ process.env.LINKEDIN_SALES_NAV_COOKIE?.trim() ||
666
+ "";
667
+ const userAgent = process.env.SALESPROMPTER_LINKEDIN_USER_AGENT?.trim() ||
668
+ "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";
669
+ if (!csrfToken || !identity || !cookie) {
670
+ throw new Error("Missing LinkedIn direct lookup tokens. Set LINKEDIN_CSRF_TOKEN, LINKEDIN_X_LI_IDENTITY, and LINKEDIN_SALES_NAV_COOKIE.");
671
+ }
672
+ return {
673
+ csrfToken,
674
+ identity,
675
+ cookie,
676
+ userAgent
677
+ };
678
+ }
679
+ function generateLinkedInSessionId() {
680
+ return Array.from({ length: 22 }, () => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".charAt(Math.floor(Math.random() * 62))).join("");
681
+ }
682
+ function buildLinkedInSalesApiUrl(firstName, lastName, companyName) {
683
+ const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
684
+ "https://www.linkedin.com";
685
+ return `${baseUrl.replace(/\/+$/, "")}/sales-api/salesApiLeadSearch?q=searchQuery&query=(recentSearchParam:(id:${Date.now()},doLogHistory:true),filters:List((type:FIRST_NAME,values:List((text:${encodeURIComponent(firstName)},selectionType:INCLUDED))),(type:LAST_NAME,values:List((text:${encodeURIComponent(lastName)},selectionType:INCLUDED))),(type:CURRENT_COMPANY,values:List((text:${encodeURIComponent(companyName)},selectionType:INCLUDED)))))&start=0&count=25&trackingParam=(sessionId:${generateLinkedInSessionId()})&decorationId=com.linkedin.sales.deco.desktop.searchv2.LeadSearchResult-14`;
686
+ }
687
+ function extractLinkedInProfileUrlFromSalesApiElement(element) {
688
+ const entityUrn = typeof element?.entityUrn === "string" ? element.entityUrn : "";
689
+ const salesIdMatch = entityUrn.match(/\(([^,]+),/);
690
+ return salesIdMatch ? `https://www.linkedin.com/in/${salesIdMatch[1]}` : null;
691
+ }
692
+ async function invokeLinkedInUrlEnrichmentDirect(params) {
693
+ const config = readLinkedInDirectLookupConfig();
694
+ const groupedContacts = new Map();
695
+ for (const contact of params.contacts) {
696
+ const key = contact.email?.trim().toLowerCase() || `contact:${contact.contact_id}`;
697
+ const existing = groupedContacts.get(key) ?? [];
698
+ existing.push(contact);
699
+ groupedContacts.set(key, existing);
700
+ }
701
+ const results = [];
702
+ let rateLimited = false;
703
+ for (const variations of groupedContacts.values()) {
704
+ const primary = variations.find((contact) => !contact.isVariation) ?? variations[0];
705
+ const blankPerson = !primary?.firstName.trim() || !primary?.lastName.trim();
706
+ if (rateLimited) {
707
+ results.push({
708
+ contact_id: primary.contact_id,
709
+ linkedin_url: null,
710
+ error: "LinkedIn rate limit"
711
+ });
712
+ continue;
713
+ }
714
+ if (blankPerson) {
715
+ results.push({
716
+ contact_id: primary.contact_id,
717
+ linkedin_url: null,
718
+ error: "Skipped blank or company-only row"
719
+ });
720
+ continue;
721
+ }
722
+ let matchedUrl = null;
723
+ let lastError = null;
724
+ for (const candidate of variations) {
725
+ const controller = new AbortController();
726
+ const timeout = setTimeout(controller.abort.bind(controller), Math.min(params.timeoutMs, 20_000));
727
+ try {
728
+ const response = await fetch(buildLinkedInSalesApiUrl(candidate.firstName, candidate.lastName, candidate.companyName), {
729
+ method: "GET",
730
+ signal: controller.signal,
731
+ headers: {
732
+ accept: "*/*",
733
+ "accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
734
+ "csrf-token": config.csrfToken,
735
+ referer: "https://www.linkedin.com/sales/search/people",
736
+ "sec-fetch-dest": "empty",
737
+ "sec-fetch-mode": "cors",
738
+ "sec-fetch-site": "same-origin",
739
+ "user-agent": config.userAgent,
740
+ "x-li-identity": config.identity,
741
+ "x-li-lang": "en_US",
742
+ "x-restli-protocol-version": "2.0.0",
743
+ cookie: config.cookie
744
+ }
745
+ });
746
+ if (response.status === 429) {
747
+ rateLimited = true;
748
+ lastError = "LinkedIn rate limit";
749
+ break;
750
+ }
751
+ if (!response.ok) {
752
+ lastError = `LinkedIn returned ${response.status}`;
753
+ continue;
754
+ }
755
+ const data = (await response.json());
756
+ const profilesFound = data.paging?.total ?? 0;
757
+ if (profilesFound > 0) {
758
+ matchedUrl = extractLinkedInProfileUrlFromSalesApiElement(data.elements?.[0]) ?? null;
759
+ if (matchedUrl) {
760
+ break;
761
+ }
762
+ }
763
+ }
764
+ catch (error) {
765
+ lastError = error instanceof Error ? error.message : "Unknown direct lookup error";
766
+ }
767
+ finally {
768
+ clearTimeout(timeout);
769
+ }
770
+ }
771
+ results.push({
772
+ contact_id: primary.contact_id,
773
+ linkedin_url: matchedUrl,
774
+ error: matchedUrl ? null : lastError
775
+ });
776
+ }
777
+ return {
778
+ success: true,
779
+ contacts: results
780
+ };
781
+ }
782
+ async function invokeLinkedInUrlEnrichmentWorkflow(params) {
783
+ const config = readPipedreamLinkedInEnrichmentConfig();
784
+ const endpoint = new URL(config.endpointUrl);
785
+ if (config.secret && !endpoint.searchParams.has("secret")) {
786
+ endpoint.searchParams.set("secret", config.secret);
787
+ }
788
+ const controller = new AbortController();
789
+ const timeout = setTimeout(() => controller.abort(), params.timeoutMs);
790
+ const headers = {
791
+ "Content-Type": "application/json",
792
+ "x-pd-external-user-id": params.externalUserId
793
+ };
794
+ if (config.secret) {
795
+ headers.Authorization = `Bearer ${config.secret}`;
796
+ headers["x-pd-secret"] = config.secret;
797
+ headers["x-secret-key"] = config.secret;
798
+ }
799
+ if (config.clientId) {
800
+ headers["X-Client-ID"] = config.clientId;
801
+ }
802
+ if (config.projectId) {
803
+ headers["X-Project-ID"] = config.projectId;
804
+ }
805
+ if (config.projectEnvironment) {
806
+ headers["X-Environment"] = config.projectEnvironment;
807
+ headers["x-pd-environment"] = config.projectEnvironment;
808
+ }
809
+ const payload = {
810
+ action: "find_linkedin_urls",
811
+ workflow_target: "find_contact_linkedin_urls",
812
+ app_source: "salesprompter_cli",
813
+ integration: "hubspot",
814
+ integration_type: "hubspot",
815
+ trigger_source: "cli_bulk_linkedin_url_lookup",
816
+ contact_count: params.contacts.length,
817
+ contacts: params.contacts,
818
+ payload: {
819
+ contacts: params.contacts
820
+ }
821
+ };
822
+ try {
823
+ const response = await fetch(endpoint, {
824
+ method: "POST",
825
+ headers,
826
+ body: JSON.stringify(payload),
827
+ signal: controller.signal
828
+ });
829
+ const bodyText = await response.text();
830
+ let parsedBody = null;
831
+ try {
832
+ parsedBody = bodyText ? JSON.parse(bodyText) : null;
833
+ }
834
+ catch {
835
+ parsedBody = bodyText;
836
+ }
837
+ return {
838
+ response,
839
+ bodyText,
840
+ parsedBody,
841
+ payload,
842
+ endpoint: endpoint.toString()
843
+ };
844
+ }
845
+ catch (error) {
846
+ if (error.name === "AbortError") {
847
+ throw new Error(`LinkedIn enrichment workflow timed out after ${params.timeoutMs}ms.`);
848
+ }
849
+ throw error;
850
+ }
851
+ finally {
852
+ clearTimeout(timeout);
853
+ }
854
+ }
855
+ async function fetchSalesNavLookupCandidates(params) {
856
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
857
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
858
+ if (!supabaseUrl || !serviceRoleKey || !params.companyName.trim()) {
859
+ return [];
860
+ }
861
+ const supabase = createClient(supabaseUrl, serviceRoleKey, {
862
+ auth: {
863
+ autoRefreshToken: false,
864
+ persistSession: false
865
+ }
866
+ });
867
+ const mapRows = (rows) => rows.map((row) => ({
868
+ orgId: row.org_id == null ? null : String(row.org_id),
869
+ fullName: row.full_name == null ? null : String(row.full_name),
870
+ companyName: row.company_name == null ? null : String(row.company_name),
871
+ title: row.title == null ? null : String(row.title),
872
+ salesNavProfileUrl: row.sales_nav_profile_url == null ? null : String(row.sales_nav_profile_url),
873
+ linkedInProfileUrl: row.linkedin_profile_url == null ? null : String(row.linkedin_profile_url)
874
+ }));
875
+ const fetchRows = async (operator, value) => {
876
+ let query = supabase
877
+ .from("linkedin_sales_nav_people")
878
+ .select("org_id,full_name,company_name,title,sales_nav_profile_url,linkedin_profile_url")
879
+ .limit(10);
880
+ if (params.orgId?.trim()) {
881
+ query = query.eq("org_id", params.orgId.trim());
882
+ }
883
+ query = operator === "eq" ? query.eq("company_name", value) : query.ilike("company_name", value);
884
+ const response = await query;
885
+ if (response.error) {
886
+ throw new Error(`Sales Nav people lookup failed with ${response.error.message}`);
887
+ }
888
+ return mapRows((response.data ?? []));
889
+ };
890
+ const exactRows = await fetchRows("eq", params.companyName);
891
+ if (exactRows.length > 0) {
892
+ return exactRows;
893
+ }
894
+ return await fetchRows("ilike", `%${params.companyName}%`);
895
+ }
896
+ async function resolveLinkedInUrlsFromSalesNavRows(params) {
897
+ const results = [];
898
+ for (const [index, row] of params.rows.entries()) {
899
+ const candidates = await fetchSalesNavLookupCandidates({
900
+ companyName: row.companyName,
901
+ orgId: params.orgId
902
+ });
903
+ const normalizedName = normalizeLooseMatchText(row.fullName);
904
+ const normalizedCompany = normalizeLooseMatchText(row.companyName);
905
+ const ranked = candidates
906
+ .map((candidate) => {
907
+ const candidateName = normalizeLooseMatchText(candidate.fullName);
908
+ const candidateCompany = normalizeLooseMatchText(candidate.companyName);
909
+ let score = 0;
910
+ if (normalizedCompany && candidateCompany === normalizedCompany)
911
+ score += 100;
912
+ else if (normalizedCompany &&
913
+ (candidateCompany.includes(normalizedCompany) || normalizedCompany.includes(candidateCompany))) {
914
+ score += 60;
915
+ }
916
+ if (normalizedName && candidateName === normalizedName)
917
+ score += 100;
918
+ else if (normalizedName &&
919
+ (candidateName.includes(normalizedName) || normalizedName.includes(candidateName))) {
920
+ score += 50;
921
+ }
922
+ if (!normalizedName && candidateCompany) {
923
+ score += 5;
924
+ }
925
+ return { candidate, score };
926
+ })
927
+ .filter((entry) => entry.score >= (normalizedName ? 120 : 60))
928
+ .sort((left, right) => {
929
+ const leftUrl = left.candidate.linkedInProfileUrl ?? left.candidate.salesNavProfileUrl;
930
+ const rightUrl = right.candidate.linkedInProfileUrl ?? right.candidate.salesNavProfileUrl;
931
+ return right.score - left.score || Number(Boolean(rightUrl)) - Number(Boolean(leftUrl));
932
+ });
933
+ const best = ranked[0]?.candidate;
934
+ const linkedinUrl = best?.linkedInProfileUrl ?? best?.salesNavProfileUrl ?? null;
935
+ results.push({
936
+ clientId: row.clientId,
937
+ fullName: row.fullName,
938
+ companyName: row.companyName,
939
+ linkedinUrl,
940
+ found: Boolean(linkedinUrl),
941
+ contactId: String(index + 1),
942
+ source: linkedinUrl ? "salesnav-supabase" : null,
943
+ matchedFullName: best?.fullName ?? null,
944
+ matchedCompanyName: best?.companyName ?? null,
945
+ matchedTitle: best?.title ?? null,
946
+ matchedOrgId: best?.orgId ?? null
947
+ });
948
+ }
949
+ return results;
950
+ }
350
951
  function buildCommandLine(args) {
351
952
  return args.map((arg) => shellQuote(arg)).join(" ");
352
953
  }
@@ -501,6 +1102,10 @@ function getOrgLabel(session) {
501
1102
  }
502
1103
  return label;
503
1104
  }
1105
+ function resolveSessionOrgId(session) {
1106
+ const orgId = session.user.orgId?.trim();
1107
+ return orgId && orgId.length > 0 ? orgId : null;
1108
+ }
504
1109
  function writeSessionSummary(session) {
505
1110
  const identity = session.user.name?.trim()
506
1111
  ? `${session.user.name} (${session.user.email})`
@@ -742,6 +1347,50 @@ async function ensureWizardSession(options) {
742
1347
  writeWizardLine();
743
1348
  return result.session;
744
1349
  }
1350
+ async function resolveLlmAuthReadiness() {
1351
+ const apiBaseUrl = process.env.SALESPROMPTER_API_BASE_URL?.trim() || "https://salesprompter.ai";
1352
+ const envToken = resolveNonInteractiveAuthToken(process.env);
1353
+ if (envToken) {
1354
+ try {
1355
+ const session = await loginWithToken(envToken, apiBaseUrl);
1356
+ return {
1357
+ ready: true,
1358
+ mode: "env_token",
1359
+ apiBaseUrl: session.apiBaseUrl,
1360
+ user: session.user,
1361
+ reason: null
1362
+ };
1363
+ }
1364
+ catch (error) {
1365
+ return {
1366
+ ready: false,
1367
+ mode: "env_token",
1368
+ apiBaseUrl,
1369
+ user: null,
1370
+ reason: error instanceof Error ? error.message : String(error)
1371
+ };
1372
+ }
1373
+ }
1374
+ try {
1375
+ const session = await requireAuthSession();
1376
+ return {
1377
+ ready: true,
1378
+ mode: "session",
1379
+ apiBaseUrl: session.apiBaseUrl,
1380
+ user: session.user,
1381
+ reason: null
1382
+ };
1383
+ }
1384
+ catch (error) {
1385
+ return {
1386
+ ready: false,
1387
+ mode: "none",
1388
+ apiBaseUrl,
1389
+ user: null,
1390
+ reason: error instanceof Error ? error.message : String(error)
1391
+ };
1392
+ }
1393
+ }
745
1394
  async function fetchWorkspaceLeadSearch(session, requestBody) {
746
1395
  const response = await fetch(`${session.apiBaseUrl}/api/cli/leads/search`, {
747
1396
  method: "POST",
@@ -802,10 +1451,14 @@ function decodeSalesNavigatorQueryParam(url) {
802
1451
  async function createWorkflowLogger(options) {
803
1452
  const traceId = options.traceId ?? buildWorkflowTraceId("salesprompter-cli");
804
1453
  const logPath = options.logPath;
1454
+ let eventStore = options.eventStore ?? null;
805
1455
  await mkdir(path.dirname(logPath), { recursive: true });
806
1456
  return {
807
1457
  traceId,
808
1458
  logPath,
1459
+ setEventStore: (nextEventStore) => {
1460
+ eventStore = nextEventStore;
1461
+ },
809
1462
  log: async (event, metadata = {}) => {
810
1463
  const entry = {
811
1464
  timestamp: new Date().toISOString(),
@@ -814,10 +1467,111 @@ async function createWorkflowLogger(options) {
814
1467
  metadata
815
1468
  };
816
1469
  await appendFile(logPath, `${JSON.stringify(entry)}\n`, "utf8");
1470
+ if (eventStore) {
1471
+ try {
1472
+ await eventStore.append({
1473
+ jobId: typeof metadata.jobId === "string" ? metadata.jobId : null,
1474
+ event,
1475
+ timestamp: entry.timestamp,
1476
+ traceId,
1477
+ source: "cli",
1478
+ metadata
1479
+ });
1480
+ }
1481
+ catch (error) {
1482
+ const message = error instanceof Error ? error.message : String(error);
1483
+ writeProgress(`[${entry.timestamp}] salesnav.event_store.write_failed: ${message}`);
1484
+ }
1485
+ }
817
1486
  writeProgress(`[${entry.timestamp}] ${event}`);
818
1487
  }
819
1488
  };
820
1489
  }
1490
+ function resolveSalesNavigatorRunEventsTable(env = process.env) {
1491
+ return env.SALESPROMPTER_SALESNAV_RUN_EVENTS_TABLE?.trim() || "salesnav_crawl_run_events";
1492
+ }
1493
+ function resolveSalesNavigatorPhantomLaneLimit(env = process.env) {
1494
+ const raw = env.SALESPROMPTER_SALESNAV_PHANTOM_LANES?.trim();
1495
+ const parsed = raw ? Number(raw) : Number.NaN;
1496
+ if (!Number.isFinite(parsed) || parsed < 1) {
1497
+ return 3;
1498
+ }
1499
+ return Math.max(1, Math.floor(parsed));
1500
+ }
1501
+ function isMissingSupabaseTableError(code, message) {
1502
+ const normalized = message.toLowerCase();
1503
+ return code === "42P01" || normalized.includes("does not exist");
1504
+ }
1505
+ async function createSalesNavigatorCrawlEventStore(options) {
1506
+ const config = (() => {
1507
+ try {
1508
+ return resolveSalesNavigatorSupabaseConfig(process.env);
1509
+ }
1510
+ catch {
1511
+ return null;
1512
+ }
1513
+ })();
1514
+ if (!config) {
1515
+ return null;
1516
+ }
1517
+ const table = resolveSalesNavigatorRunEventsTable(process.env);
1518
+ const client = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
1519
+ auth: {
1520
+ persistSession: false,
1521
+ autoRefreshToken: false
1522
+ }
1523
+ });
1524
+ let writable = true;
1525
+ return {
1526
+ append: async (entry) => {
1527
+ if (!writable) {
1528
+ return;
1529
+ }
1530
+ const { error } = await client.from(table).insert({
1531
+ org_id: options.orgId,
1532
+ job_id: entry.jobId,
1533
+ event: entry.event,
1534
+ event_time: entry.timestamp,
1535
+ trace_id: entry.traceId,
1536
+ source: entry.source,
1537
+ payload: entry.metadata
1538
+ });
1539
+ if (!error) {
1540
+ return;
1541
+ }
1542
+ if (isMissingSupabaseTableError(error.code, error.message)) {
1543
+ writable = false;
1544
+ return;
1545
+ }
1546
+ throw new Error(`Failed to write crawl event to Supabase: ${error.message}`);
1547
+ },
1548
+ listRecentForJob: async (jobId, limit) => {
1549
+ const { data, error } = await client
1550
+ .from(table)
1551
+ .select("job_id,event,event_time,trace_id,source,payload")
1552
+ .eq("org_id", options.orgId)
1553
+ .eq("job_id", jobId)
1554
+ .order("event_time", { ascending: false })
1555
+ .limit(limit);
1556
+ if (error) {
1557
+ if (isMissingSupabaseTableError(error.code, error.message)) {
1558
+ return [];
1559
+ }
1560
+ throw new Error(`Failed to fetch crawl events from Supabase: ${error.message}`);
1561
+ }
1562
+ return (data ?? []).map((row) => ({
1563
+ jobId: typeof row.job_id === "string" ? row.job_id : null,
1564
+ event: typeof row.event === "string" ? row.event : "unknown",
1565
+ timestamp: typeof row.event_time === "string" ? row.event_time : new Date().toISOString(),
1566
+ traceId: typeof row.trace_id === "string" ? row.trace_id : null,
1567
+ source: "cli",
1568
+ metadata: row.payload && typeof row.payload === "object" && !Array.isArray(row.payload)
1569
+ ? row.payload
1570
+ : {}
1571
+ }));
1572
+ }
1573
+ };
1574
+ }
821
1575
  function summarizeSalesNavigatorQuery(url, appliedFilters) {
822
1576
  return {
823
1577
  url,
@@ -1041,6 +1795,13 @@ async function runSalesNavigatorFromProductCategoryWorkflow(options) {
1041
1795
  return { outPath, payload };
1042
1796
  }
1043
1797
  let session = await requireAuthSession();
1798
+ const sessionOrgId = resolveSessionOrgId(session);
1799
+ if (sessionOrgId) {
1800
+ const eventStore = await createSalesNavigatorCrawlEventStore({
1801
+ orgId: sessionOrgId
1802
+ });
1803
+ logger.setEventStore(eventStore);
1804
+ }
1044
1805
  let uploaded = null;
1045
1806
  if (!options.skipProductUpload) {
1046
1807
  await logger.log("linkedin.catalog.upload.started", {
@@ -1155,7 +1916,57 @@ async function runSalesNavigatorFromProductCategoryWorkflow(options) {
1155
1916
  summary: crawlSummary
1156
1917
  });
1157
1918
  }
1158
- const summary = buildSalesNavigatorWorkflowSummary(crawls);
1919
+ let summary = buildSalesNavigatorWorkflowSummary(crawls);
1920
+ const sessionPoolBlocked = crawls.some((crawl) => crawl.lastOutcome?.errorCode === "blocked_no_valid_salesnav_session");
1921
+ if (sessionPoolBlocked && sessionOrgId) {
1922
+ try {
1923
+ await logger.log("salesnav.bigquery-fallback.started", {
1924
+ orgId: sessionOrgId,
1925
+ scope: "all-sales-people",
1926
+ reason: "blocked_no_valid_salesnav_session",
1927
+ mode: "silent"
1928
+ });
1929
+ const fallbackConfig = resolveSalesNavigatorHistoricalBackfillConfig(process.env);
1930
+ const fallbackSummary = await ensureSalesNavigatorPeopleCount({
1931
+ config: fallbackConfig,
1932
+ orgId: sessionOrgId,
1933
+ targetCount: Number.MAX_SAFE_INTEGER,
1934
+ scope: "all-sales-people",
1935
+ startOffset: 0,
1936
+ resumedFromHistory: false,
1937
+ windowSize: 2_500,
1938
+ maxWindows: 1,
1939
+ pageSize: salesNavigatorHistoricalBackfillDefaults.pageSize,
1940
+ upsertBatchSize: salesNavigatorHistoricalBackfillDefaults.upsertBatchSize,
1941
+ minUpsertBatchSize: salesNavigatorHistoricalBackfillDefaults.minUpsertBatchSize,
1942
+ maxUpsertRetries: salesNavigatorHistoricalBackfillDefaults.maxUpsertRetries,
1943
+ retryDelayMs: salesNavigatorHistoricalBackfillDefaults.retryDelayMs
1944
+ });
1945
+ const importedDelta = Math.max(0, fallbackSummary.currentCount - fallbackSummary.initialCount);
1946
+ if (importedDelta > 0) {
1947
+ summary = {
1948
+ ...summary,
1949
+ workflowStatus: "completed",
1950
+ totalImportedPeople: summary.totalImportedPeople + importedDelta
1951
+ };
1952
+ }
1953
+ await logger.log("salesnav.bigquery-fallback.completed", {
1954
+ orgId: sessionOrgId,
1955
+ status: fallbackSummary.status,
1956
+ completedWindows: fallbackSummary.completedWindows,
1957
+ initialCount: fallbackSummary.initialCount,
1958
+ currentCount: fallbackSummary.currentCount,
1959
+ importedDelta
1960
+ });
1961
+ }
1962
+ catch (fallbackError) {
1963
+ await logger.log("salesnav.bigquery-fallback.failed", {
1964
+ orgId: sessionOrgId,
1965
+ message: fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
1966
+ stack: fallbackError instanceof Error ? fallbackError.stack ?? null : null
1967
+ });
1968
+ }
1969
+ }
1159
1970
  const payload = {
1160
1971
  status: "ok",
1161
1972
  dryRun: false,
@@ -1283,6 +2094,26 @@ async function uploadLinkedInProductsCatalog(session, payload, batchSize = 100,
1283
2094
  }
1284
2095
  return { imported, upserted };
1285
2096
  }
2097
+ async function launchLinkedInCompaniesBackfill(session, payload) {
2098
+ const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/linkedin-companies/backfill`, {
2099
+ method: 'POST',
2100
+ headers: {
2101
+ 'Content-Type': 'application/json',
2102
+ Authorization: `Bearer ${currentSession.accessToken}`
2103
+ },
2104
+ body: JSON.stringify(payload)
2105
+ }), LinkedInCompanyBackfillLaunchResponseSchema);
2106
+ return value;
2107
+ }
2108
+ async function fetchLinkedInCompaniesBackfillStatus(session, payload) {
2109
+ const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/linkedin-companies/status?clientId=${encodeURIComponent(String(payload.clientId))}&containerId=${encodeURIComponent(payload.containerId)}`, {
2110
+ method: "GET",
2111
+ headers: {
2112
+ Authorization: `Bearer ${currentSession.accessToken}`
2113
+ }
2114
+ }), LinkedInCompanyBackfillStatusResponseSchema);
2115
+ return value;
2116
+ }
1286
2117
  function serializeSalesNavigatorFiltersForApi(filters) {
1287
2118
  return filters.map((filter) => ({
1288
2119
  type: filter.type,
@@ -1526,6 +2357,55 @@ function isSalesNavigatorAgentBusyError(error) {
1526
2357
  const message = error instanceof Error ? error.message : String(error);
1527
2358
  return /parallel executions limit/i.test(message);
1528
2359
  }
2360
+ async function drainLinkedInCompanyBackfill(session, payload) {
2361
+ let batches = 0;
2362
+ let startedCompanies = 0;
2363
+ let remaining = 0;
2364
+ let consecutiveBusyPolls = 0;
2365
+ for (;;) {
2366
+ let launched;
2367
+ try {
2368
+ launched = await launchLinkedInCompaniesBackfill(session, payload);
2369
+ }
2370
+ catch (error) {
2371
+ if (isSalesNavigatorAgentBusyError(error)) {
2372
+ consecutiveBusyPolls += 1;
2373
+ if (consecutiveBusyPolls === 1) {
2374
+ writeProgress("Company enrichment is already running. Waiting for the current batch to finish...");
2375
+ }
2376
+ await delay(30_000);
2377
+ continue;
2378
+ }
2379
+ throw error;
2380
+ }
2381
+ consecutiveBusyPolls = 0;
2382
+ if (!launched.launched || !launched.containerId) {
2383
+ return {
2384
+ completed: true,
2385
+ batches,
2386
+ startedCompanies,
2387
+ remaining: launched.candidates.length
2388
+ };
2389
+ }
2390
+ batches += 1;
2391
+ startedCompanies += launched.candidates.length;
2392
+ writeProgress(`Started company enrichment batch ${batches} for ${launched.candidates.length} companies.`);
2393
+ for (;;) {
2394
+ const status = await fetchLinkedInCompaniesBackfillStatus(session, {
2395
+ clientId: payload.clientId,
2396
+ containerId: launched.containerId
2397
+ });
2398
+ remaining = status.remaining;
2399
+ if (!status.running && status.processed) {
2400
+ writeProgress(remaining > 0
2401
+ ? `${remaining} companies still waiting. Starting the next batch...`
2402
+ : "Company enrichment finished.");
2403
+ break;
2404
+ }
2405
+ await delay(15_000);
2406
+ }
2407
+ }
2408
+ }
1529
2409
  function isSalesNavigatorSessionError(error) {
1530
2410
  if (error instanceof SalesNavigatorExportRequestError) {
1531
2411
  if (error.errorCode === "invalid_session") {
@@ -1536,7 +2416,7 @@ function isSalesNavigatorSessionError(error) {
1536
2416
  }
1537
2417
  }
1538
2418
  const message = error instanceof Error ? error.message : String(error);
1539
- return /can't connect profile|sales navigator account|upsell|linkedin session invalid|linkedin_rate_limited|too many requests|rate.?limit|invalid session cookie/i.test(message);
2419
+ return /can't connect profile|sales navigator account|upsell|linkedin session invalid|linkedin_rate_limited|too many requests|rate.?limit|invalid session cookie|disconnected by linkedin|linkedin-disconnected-while-using-api|provide a new linkedin session cookie/i.test(message);
1540
2420
  }
1541
2421
  function isSalesNavigatorResultArtifactError(error) {
1542
2422
  if (error instanceof SalesNavigatorExportRequestError && error.errorCode === "phantombuster_result_invalid") {
@@ -2165,7 +3045,23 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
2165
3045
  let job = null;
2166
3046
  let idlePollCount = 0;
2167
3047
  let lastOutcome = null;
2168
- const parallelExports = Math.max(1, options.parallelExports);
3048
+ const phantomLaneLimit = resolveSalesNavigatorPhantomLaneLimit(process.env);
3049
+ const parallelExports = Math.max(1, Math.min(options.parallelExports, phantomLaneLimit));
3050
+ await options.logger?.log("salesnav.crawl.parallel.config", {
3051
+ requestedParallelExports: options.parallelExports,
3052
+ effectiveParallelExports: parallelExports,
3053
+ phantomLaneLimit
3054
+ });
3055
+ if (!runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
3056
+ process.stderr.write(`Sales Navigator parallel exports: requested=${options.parallelExports}, lane_limit=${phantomLaneLimit}, effective=${parallelExports}\n`);
3057
+ }
3058
+ if (parallelExports < options.parallelExports) {
3059
+ await options.logger?.log("salesnav.crawl.parallel.capped", {
3060
+ requestedParallelExports: options.parallelExports,
3061
+ effectiveParallelExports: parallelExports,
3062
+ phantomLaneLimit
3063
+ });
3064
+ }
2169
3065
  const inFlight = new Map();
2170
3066
  let nextSlot = 0;
2171
3067
  let noMoreClaimableWork = false;
@@ -2435,13 +3331,13 @@ async function runProductMarketWizard(rl) {
2435
3331
  writeWizardLine(` ${buildCommandLine(commandArgs)}`);
2436
3332
  }
2437
3333
  async function runVendorShortcutWizard(rl) {
2438
- writeWizardSection("Built-in Deel shortcut", "Use the built-in Deel ICP template and search your workspace lead data.");
3334
+ writeWizardSection("Built-in vendor shortcut", "Use a built-in vendor ICP template and search your workspace lead data.");
2439
3335
  const reference = parseCompanyReference(await promptText(rl, "Which company shortcut should I use?", {
2440
3336
  required: true
2441
3337
  }));
2442
3338
  writeWizardLine();
2443
3339
  if (reference.vendorTemplate !== "deel") {
2444
- throw new Error("The built-in shortcut only supports Deel right now. Use deel.com or the Deel LinkedIn company page.");
3340
+ throw new Error("The built-in shortcut currently supports Deel only. Use deel.com or the Deel LinkedIn company page.");
2445
3341
  }
2446
3342
  const market = await promptChoice(rl, "Where do you want to search?", [
2447
3343
  { value: "dach", label: "DACH", description: "Germany, Austria, Switzerland" },
@@ -2578,14 +3474,14 @@ async function runWizard(options) {
2578
3474
  },
2579
3475
  {
2580
3476
  value: "reference-company",
2581
- label: "Use the built-in Deel shortcut",
2582
- description: "Generate the saved Deel ICP and search workspace leads",
2583
- aliases: ["deel", "shortcut", "vendor template", "quick deel"]
3477
+ label: "Use a built-in vendor shortcut",
3478
+ description: "Generate the saved vendor ICP and search workspace leads",
3479
+ aliases: ["vendor", "shortcut", "vendor template", "quick template"]
2584
3480
  },
2585
3481
  {
2586
3482
  value: "target-company",
2587
3483
  label: "Find people at a specific company",
2588
- description: "Example: find people at deel.com",
3484
+ description: "Example: find people at company.com",
2589
3485
  aliases: ["target company", "company", "find people", "people at a company", "lead generation"]
2590
3486
  },
2591
3487
  {
@@ -2750,10 +3646,33 @@ async function fetchHistoricalQueryRows(tables) {
2750
3646
  }
2751
3647
  program
2752
3648
  .name("salesprompter")
2753
- .description("Sales workflow CLI for LinkedIn product discovery, Sales Navigator crawling, lead enrichment, scoring, and sync.")
3649
+ .description("Sales workflow CLI for guided lead generation, enrichment, scoring, and sync.")
2754
3650
  .version(packageVersion)
2755
3651
  .option("--json", "Emit compact machine-readable JSON output", false)
2756
3652
  .option("--quiet", "Suppress successful stdout output", false);
3653
+ program.configureHelp({
3654
+ visibleCommands(cmd) {
3655
+ return cmd.commands
3656
+ .filter((subcommand) => {
3657
+ const maybeHidden = subcommand;
3658
+ return !maybeHidden._hidden && helpVisibleCommandNames.has(subcommand.name());
3659
+ })
3660
+ .sort((left, right) => left.name().localeCompare(right.name()));
3661
+ },
3662
+ subcommandTerm(cmd) {
3663
+ const visibleName = helpAliasByCommandName.get(cmd.name()) || cmd.name();
3664
+ const args = cmd.registeredArguments.map((arg) => formatHelpArgumentTerm(arg)).join(" ");
3665
+ return visibleName + (cmd.options.length ? " [options]" : "") + (args ? " " + args : "");
3666
+ }
3667
+ });
3668
+ program.addHelpText("after", `
3669
+ LLM operator tips:
3670
+ - Prefer non-interactive auth: set SALESPROMPTER_TOKEN (+ optional SALESPROMPTER_API_BASE_URL).
3671
+ - Use machine output for tools: add --json.
3672
+ - One-shot leads flow: leads:pipeline --icp <path> --domain <company.com> --count <n>.
3673
+ - Preview contact enrichment first: contacts:resolve-profiles --in <contacts.tsv> --dry-run.
3674
+ - For bigger runs, start with a small sample before processing the full file.
3675
+ `);
2757
3676
  program
2758
3677
  .command("auth:login")
2759
3678
  .description("Authenticate CLI with a Salesprompter app token, or device flow if the app supports it.")
@@ -2806,6 +3725,125 @@ program
2806
3725
  createdAt: verifiedSession.createdAt
2807
3726
  });
2808
3727
  });
3728
+ program
3729
+ .command("llm:ready")
3730
+ .description("Return LLM/operator readiness info with recommended next commands.")
3731
+ .option("--icp <path>", "Optional ICP path for lead pipeline examples", "/tmp/deel-icp.json")
3732
+ .option("--domain <domain>", "Optional domain for lead pipeline examples", "deel.com")
3733
+ .action(async (options) => {
3734
+ const readiness = await resolveLlmAuthReadiness();
3735
+ const leadPipelineCommand = [
3736
+ "salesprompter --json leads:pipeline",
3737
+ `--icp ${shellQuote(String(options.icp))}`,
3738
+ `--domain ${shellQuote(String(options.domain))}`,
3739
+ "--count 50",
3740
+ "--out-prefix /tmp/leads-run"
3741
+ ].join(" ");
3742
+ const salesNavCountCommand = [
3743
+ "salesprompter --json salesnav:count",
3744
+ "--query-url \"https://www.linkedin.com/sales/search/people?query=...\"",
3745
+ "--number-of-profiles 1"
3746
+ ].join(" ");
3747
+ printOutput({
3748
+ status: "ok",
3749
+ ready: readiness.ready,
3750
+ auth: {
3751
+ mode: readiness.mode,
3752
+ apiBaseUrl: readiness.apiBaseUrl,
3753
+ user: readiness.user,
3754
+ reason: readiness.reason
3755
+ },
3756
+ recommended: {
3757
+ leadPipeline: leadPipelineCommand,
3758
+ salesNavCount: salesNavCountCommand
3759
+ },
3760
+ tips: [
3761
+ "Use SALESPROMPTER_TOKEN for non-interactive runs.",
3762
+ "Add --json for machine-readable outputs.",
3763
+ "For live crawls, start with --max-slices 1."
3764
+ ]
3765
+ });
3766
+ });
3767
+ program
3768
+ .command("contacts:find-linkedin-urls")
3769
+ .alias("contacts:resolve-profiles")
3770
+ .description("Resolve profile URLs for a pasted contacts list directly in the CLI.")
3771
+ .option("--in <path>", "Input TSV/CSV/JSON file path. Omit to read from stdin.")
3772
+ .option("--out <path>", "Optional output JSON path for the enriched rows.")
3773
+ .option("--org-id <id>", "Optional Sales Nav workspace org id for the lookup-first pass.")
3774
+ .option("--external-user-id <id>", "Deprecated compatibility option. Ignored by the direct CLI lookup.")
3775
+ .option("--timeout-ms <number>", "Lookup timeout in milliseconds", "30000")
3776
+ .option("--dry-run", "Preview the normalized payload without calling LinkedIn", false)
3777
+ .action(async (options) => {
3778
+ const timeoutMs = z.coerce.number().int().min(1000).max(300000).parse(options.timeoutMs);
3779
+ const inputContent = options.in ? await readFile(options.in, "utf8") : await readAllStdin();
3780
+ const rows = parseLinkedInUrlLookupInput(inputContent);
3781
+ if (rows.length === 0) {
3782
+ throw new Error("No contact rows found. Provide TSV/CSV/JSON input via --in or stdin.");
3783
+ }
3784
+ let sessionOrgId = "";
3785
+ if (!shouldBypassAuth()) {
3786
+ try {
3787
+ const session = await requireAuthSession();
3788
+ sessionOrgId = session.user.orgId ?? "";
3789
+ }
3790
+ catch {
3791
+ sessionOrgId = "";
3792
+ }
3793
+ }
3794
+ const contacts = toLinkedInUrlLookupContacts(rows);
3795
+ if (options.dryRun) {
3796
+ const payload = {
3797
+ status: "ok",
3798
+ dryRun: true,
3799
+ orgId: String(options.orgId ?? "").trim() || null,
3800
+ contacts: contacts.length,
3801
+ sample: contacts.slice(0, 5)
3802
+ };
3803
+ if (options.out) {
3804
+ await writeJsonFile(options.out, payload);
3805
+ }
3806
+ printOutput(payload);
3807
+ return;
3808
+ }
3809
+ const enrichedRows = await resolveLinkedInUrlsFromSalesNavRows({
3810
+ rows,
3811
+ orgId: String(options.orgId ?? "").trim() || undefined
3812
+ });
3813
+ let directAttempted = false;
3814
+ const missingRows = enrichedRows.filter((row) => !row.found);
3815
+ if (missingRows.length > 0) {
3816
+ directAttempted = true;
3817
+ const directContacts = contacts.filter((contact) => missingRows.some((row) => row.contactId === contact.contact_id));
3818
+ const result = await invokeLinkedInUrlEnrichmentDirect({
3819
+ contacts: directContacts,
3820
+ timeoutMs
3821
+ });
3822
+ const linkedInUrlByContactId = new Map(result.contacts.map((contact) => [contact.contact_id, contact.linkedin_url]));
3823
+ for (const row of enrichedRows) {
3824
+ if (row.found)
3825
+ continue;
3826
+ const linkedinUrl = linkedInUrlByContactId.get(row.contactId) ?? null;
3827
+ if (linkedinUrl) {
3828
+ row.linkedinUrl = linkedinUrl;
3829
+ row.found = true;
3830
+ row.source = "linkedin-direct";
3831
+ }
3832
+ }
3833
+ }
3834
+ const payload = {
3835
+ status: "ok",
3836
+ orgId: String(options.orgId ?? "").trim() || null,
3837
+ requested: rows.length,
3838
+ found: enrichedRows.filter((row) => row.found).length,
3839
+ directAttempted,
3840
+ rows: enrichedRows
3841
+ };
3842
+ if (options.out) {
3843
+ await writeJsonFile(options.out, payload);
3844
+ }
3845
+ printOutput(payload);
3846
+ });
2809
3847
  program
2810
3848
  .command("auth:logout")
2811
3849
  .description("Remove local CLI auth session.")
@@ -2816,7 +3854,10 @@ program
2816
3854
  program.hook("preAction", async (_thisCommand, actionCommand) => {
2817
3855
  applyGlobalOutputOptions(actionCommand);
2818
3856
  const commandName = actionCommand.name();
2819
- if (commandName.startsWith("auth:") || commandName === "wizard") {
3857
+ if (commandName.startsWith("auth:") ||
3858
+ commandName === "wizard" ||
3859
+ commandName === "llm:ready" ||
3860
+ commandName === "contacts:find-linkedin-urls") {
2820
3861
  return;
2821
3862
  }
2822
3863
  const commandOptions = actionCommand.opts();
@@ -2829,6 +3870,11 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
2829
3870
  if (shouldBypassAuth()) {
2830
3871
  return;
2831
3872
  }
3873
+ const envToken = resolveNonInteractiveAuthToken(process.env);
3874
+ if (envToken) {
3875
+ await loginWithToken(envToken, process.env.SALESPROMPTER_API_BASE_URL?.trim());
3876
+ return;
3877
+ }
2832
3878
  try {
2833
3879
  const session = await requireAuthSession();
2834
3880
  if (session.expiresAt !== undefined && Date.now() >= Date.parse(session.expiresAt)) {
@@ -2836,7 +3882,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
2836
3882
  await ensureInteractiveAuthSession(session.apiBaseUrl);
2837
3883
  return;
2838
3884
  }
2839
- throw new Error("session expired. Run `salesprompter auth:login`.");
3885
+ throw new Error("session expired. Run `salesprompter auth:login` or set SALESPROMPTER_TOKEN for non-interactive runs.");
2840
3886
  }
2841
3887
  }
2842
3888
  catch (error) {
@@ -2852,7 +3898,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
2852
3898
  program
2853
3899
  .command("account:resolve")
2854
3900
  .description("Resolve a target company into a normalized account profile.")
2855
- .requiredOption("--domain <domain>", "Company domain like deel.com")
3901
+ .requiredOption("--domain <domain>", "Company domain like company.com")
2856
3902
  .option("--company-name <name>", "Optional company name override")
2857
3903
  .option("--icp <path>", "Optional path to ICP JSON for industry/region/title hints")
2858
3904
  .requiredOption("--out <path>", "Output file path")
@@ -2911,7 +3957,7 @@ program
2911
3957
  program
2912
3958
  .command("icp:vendor")
2913
3959
  .description("Create a vendor-specific ICP template.")
2914
- .requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
3960
+ .requiredOption("--vendor <vendor>", "Vendor template name")
2915
3961
  .option("--market <market>", "global|europe|dach", "dach")
2916
3962
  .option("--out <path>", "Optional output file path")
2917
3963
  .action(async (options) => {
@@ -2926,7 +3972,7 @@ program
2926
3972
  program
2927
3973
  .command("icp:from-historical-queries:bq")
2928
3974
  .description("Build a vendor ICP from historical BigQuery query patterns.")
2929
- .requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
3975
+ .requiredOption("--vendor <vendor>", "Vendor template name")
2930
3976
  .option("--market <market>", "global|europe|dach", "dach")
2931
3977
  .option("--tables <items>", "Comma-separated BigQuery tables with a query column", "leadLists_raw,leadLists_unique,linkedinSearchExport_people_unique,salesNavigatorSearchExport_companies_unique,snse_containers_input")
2932
3978
  .option("--search-kind <kind>", "all|people|sales-people|sales-company", "sales-people")
@@ -2966,7 +4012,7 @@ program
2966
4012
  .description("Generate leads for a target account or from fallback seeds.")
2967
4013
  .requiredOption("--icp <path>", "Path to ICP JSON")
2968
4014
  .option("--count <number>", "Number of leads to generate", "10")
2969
- .option("--domain <domain>", "Target a specific company domain like deel.com")
4015
+ .option("--domain <domain>", "Target a specific company domain like company.com")
2970
4016
  .option("--company-domain <domain>", "Deprecated alias for --domain")
2971
4017
  .option("--company-name <name>", "Optional company name override for a targeted domain")
2972
4018
  .requiredOption("--out <path>", "Output file path")
@@ -3015,6 +4061,51 @@ program
3015
4061
  await writeJsonFile(options.out, scored);
3016
4062
  printOutput({ status: "ok", scored: scored.length, out: options.out });
3017
4063
  });
4064
+ program
4065
+ .command("leads:pipeline")
4066
+ .description("Run one-shot lead generation pipeline: generate -> enrich -> score.")
4067
+ .requiredOption("--icp <path>", "Path to ICP JSON")
4068
+ .option("--count <number>", "Number of leads to generate", "10")
4069
+ .option("--domain <domain>", "Target a specific company domain like company.com")
4070
+ .option("--company-domain <domain>", "Deprecated alias for --domain")
4071
+ .option("--company-name <name>", "Optional company name override for a targeted domain")
4072
+ .option("--out-prefix <path>", "Output path prefix (writes <prefix>-leads.json, <prefix>-enriched.json, <prefix>-scored.json)", "./data/leads-pipeline")
4073
+ .action(async (options) => {
4074
+ const icp = await readJsonFile(options.icp, IcpSchema);
4075
+ const count = z.coerce.number().int().min(1).max(1000).parse(options.count);
4076
+ const domain = options.domain ?? options.companyDomain;
4077
+ const target = {
4078
+ companyDomain: domain,
4079
+ companyName: options.companyName
4080
+ };
4081
+ const outPrefix = String(options.outPrefix);
4082
+ const leadsOut = `${outPrefix}-leads.json`;
4083
+ const enrichedOut = `${outPrefix}-enriched.json`;
4084
+ const scoredOut = `${outPrefix}-scored.json`;
4085
+ const generated = await leadProvider.generateLeads(icp, count, target);
4086
+ await writeJsonFile(leadsOut, generated.leads);
4087
+ const enriched = await enrichmentProvider.enrichLeads(generated.leads);
4088
+ await writeJsonFile(enrichedOut, enriched);
4089
+ const scored = await scoringProvider.scoreLeads(icp, enriched);
4090
+ await writeJsonFile(scoredOut, scored);
4091
+ printOutput({
4092
+ status: "ok",
4093
+ provider: generated.provider,
4094
+ mode: generated.mode,
4095
+ account: generated.account,
4096
+ warnings: generated.warnings,
4097
+ counts: {
4098
+ generated: generated.leads.length,
4099
+ enriched: enriched.length,
4100
+ scored: scored.length
4101
+ },
4102
+ outputs: {
4103
+ leads: leadsOut,
4104
+ enriched: enrichedOut,
4105
+ scored: scoredOut
4106
+ }
4107
+ });
4108
+ });
3018
4109
  program
3019
4110
  .command("sync:crm")
3020
4111
  .description("Dry-run sync scored leads into a CRM target.")
@@ -3044,9 +4135,56 @@ program
3044
4135
  });
3045
4136
  printOutput({ status: "ok", ...result });
3046
4137
  });
4138
+ program
4139
+ .command("linkedin-companies:backfill")
4140
+ .alias("companies:enrich")
4141
+ .description("Backfill missing or unavailable company profiles for the current workspace.")
4142
+ .requiredOption("--client-id <number>", "Legacy BigQuery clientId to backfill")
4143
+ .option("--limit <number>", "Maximum companies to scrape in one run", "25")
4144
+ .option("--concurrency <number>", "How many LinkedIn company pages to scrape in parallel", "4")
4145
+ .option("--dry-run", "Preview the scrape result and generated MERGE SQL without writing to BigQuery", false)
4146
+ .action(async (options) => {
4147
+ const clientId = z.coerce.number().int().positive().parse(options.clientId);
4148
+ const limit = z.coerce.number().int().min(1).max(500).parse(options.limit);
4149
+ const concurrency = z.coerce.number().int().min(1).max(20).parse(options.concurrency);
4150
+ if (!options.dryRun && !shouldBypassAuth()) {
4151
+ const session = await requireAuthSession();
4152
+ const drained = await drainLinkedInCompanyBackfill(session, {
4153
+ clientId,
4154
+ limit
4155
+ });
4156
+ printOutput({
4157
+ status: "ok",
4158
+ dryRun: false,
4159
+ clientId,
4160
+ completed: drained.completed,
4161
+ batches: drained.batches,
4162
+ started: drained.startedCompanies,
4163
+ remaining: drained.remaining
4164
+ });
4165
+ return;
4166
+ }
4167
+ const result = await backfillLinkedInCompanies({
4168
+ clientId,
4169
+ limit,
4170
+ concurrency,
4171
+ dryRun: true
4172
+ });
4173
+ printOutput({
4174
+ status: "ok",
4175
+ dryRun: true,
4176
+ clientId,
4177
+ discovered: result.candidates.length,
4178
+ unavailable: result.results.filter((row) => row.unavailable).length,
4179
+ stored: 0,
4180
+ results: result.results,
4181
+ mergeSql: result.mergeSql
4182
+ });
4183
+ });
3047
4184
  program
3048
4185
  .command("linkedin-products:scrape")
3049
- .description("Resolve a company or LinkedIn product into a LinkedIn product category, scrape that catalog, and upload it to Salesprompter.")
4186
+ .alias("market:scrape")
4187
+ .description("Turn a company or product input into a reusable market catalog and upload it to Salesprompter.")
3050
4188
  .requiredOption("--input <value>", "Company domain, LinkedIn company URL, LinkedIn product URL, LinkedIn category URL, or LinkedIn product search URL")
3051
4189
  .option("--max-pages <number>", "Maximum LinkedIn category pages to fetch", "25")
3052
4190
  .option("--limit <number>", "Optional cap on the number of products to keep")
@@ -3093,7 +4231,8 @@ program
3093
4231
  });
3094
4232
  program
3095
4233
  .command("salesnav:from-product-category")
3096
- .description("Crawl a LinkedIn product category, derive intended-role title searches, then run durable Sales Navigator crawls that export through Phantombuster into Salesprompter.")
4234
+ .alias("leads:discover")
4235
+ .description("Start from a market or product input, derive the right buyer searches, and discover leads.")
3097
4236
  .requiredOption("--input <value>", "Company domain, LinkedIn company URL, LinkedIn product URL, LinkedIn category URL, or LinkedIn product search URL")
3098
4237
  .option("--max-pages <number>", "Maximum LinkedIn category pages to fetch", "25")
3099
4238
  .option("--product-limit <number>", "Optional cap on the number of LinkedIn products to inspect")
@@ -3306,16 +4445,31 @@ program
3306
4445
  });
3307
4446
  program
3308
4447
  .command("salesnav:deel-locale-export")
4448
+ .alias("salesnav:vendor-locale-export")
3309
4449
  .description("Export the Supabase Sales Navigator Deel corpus into German-vs-English outreach backlog files.")
3310
4450
  .option("--org-id <id>", "Workspace org id. Defaults to the active CLI org.")
3311
4451
  .option("--limit <number>", "Maximum number of Supabase rows to process", "250000")
3312
4452
  .option("--page-size <number>", "Supabase page size per request", "1000")
3313
4453
  .option("--title-filter <mode>", "deel-hr|all", "deel-hr")
4454
+ .option("--min-email-score <number>", "Minimum email score when enriching leadPool rows", "70")
4455
+ .option("--enrich", "Join the kept Sales Navigator rows to leadPool_new emails", false)
4456
+ .option("--campaign-id <id>", "Fallback Instantly campaign id for both locales")
4457
+ .option("--campaign-id-de <id>", "Instantly campaign id for German leads")
4458
+ .option("--campaign-id-en <id>", "Instantly campaign id for English leads")
4459
+ .option("--apply", "Create leads in Instantly instead of export-only mode", false)
4460
+ .option("--allow-duplicates", "Do not dedupe leads that already exist in the campaign", false)
3314
4461
  .requiredOption("--out-dir <path>", "Output directory for summary and locale CSV files")
3315
4462
  .action(async (options) => {
3316
4463
  const limit = z.coerce.number().int().min(1).max(500000).parse(options.limit);
3317
4464
  const pageSize = z.coerce.number().int().min(1).max(1000).parse(options.pageSize);
3318
4465
  const titleFilter = z.enum(["deel-hr", "all"]).parse(options.titleFilter);
4466
+ const minEmailScore = z.coerce.number().int().min(0).max(100).parse(options.minEmailScore);
4467
+ const enrich = Boolean(options.enrich);
4468
+ const fallbackCampaignId = options.campaignId?.trim();
4469
+ const campaignIdDe = options.campaignIdDe?.trim() ?? fallbackCampaignId;
4470
+ const campaignIdEn = options.campaignIdEn?.trim() ?? fallbackCampaignId;
4471
+ const applySync = Boolean(options.apply);
4472
+ const allowDuplicates = Boolean(options.allowDuplicates);
3319
4473
  let sessionOrgId = null;
3320
4474
  if (!shouldBypassAuth()) {
3321
4475
  const session = await requireAuthSession();
@@ -3374,6 +4528,7 @@ program
3374
4528
  de: [],
3375
4529
  en: []
3376
4530
  };
4531
+ const keptRows = [];
3377
4532
  const selectFields = [
3378
4533
  "id",
3379
4534
  "org_id",
@@ -3430,6 +4585,7 @@ program
3430
4585
  const preparedRows = relevantRows.map((row) => normalizeDeelSalesNavRow(row));
3431
4586
  const deRows = preparedRows.filter((row) => row.language === "de");
3432
4587
  const enRows = preparedRows.filter((row) => row.language === "en");
4588
+ keptRows.push(...preparedRows);
3433
4589
  if (deRows.length > 0) {
3434
4590
  await appendFile(deCsvPath, `${buildDeelSalesNavCsvLines(deRows)}\n`, "utf8");
3435
4591
  }
@@ -3492,6 +4648,100 @@ program
3492
4648
  }
3493
4649
  const keptTotal = localeCounts.de + localeCounts.en;
3494
4650
  const percentage = (count, base = keptTotal) => base > 0 ? Number(((count / base) * 100).toFixed(2)) : 0;
4651
+ let enrichmentSummary = null;
4652
+ if (enrich) {
4653
+ const localeByContactId = new Map();
4654
+ for (const row of keptRows) {
4655
+ const contactId = extractSalesNavContactId(row.salesNavProfileUrl);
4656
+ if (contactId) {
4657
+ localeByContactId.set(contactId, row.language);
4658
+ }
4659
+ }
4660
+ const contactIds = Array.from(localeByContactId.keys());
4661
+ if (contactIds.length === 0) {
4662
+ enrichmentSummary = {
4663
+ contactIds: 0,
4664
+ enrichedRows: 0,
4665
+ missingContactIds: [],
4666
+ files: {
4667
+ all: "",
4668
+ de: "",
4669
+ en: ""
4670
+ },
4671
+ syncResults: []
4672
+ };
4673
+ }
4674
+ else {
4675
+ const enrichedRows = [];
4676
+ for (const chunk of chunkArray(contactIds, 400)) {
4677
+ const sql = buildDeelLeadPoolContactSql({ contactIds: chunk, minEmailScore });
4678
+ const rows = await runBigQueryRows(sql, { maxRows: chunk.length * 2 });
4679
+ enrichedRows.push(...rows);
4680
+ }
4681
+ const normalizedRows = normalizeDeelOutreachRows(enrichedRows);
4682
+ const foundContactIds = new Set();
4683
+ for (const row of normalizedRows) {
4684
+ if (row.contactId) {
4685
+ foundContactIds.add(row.contactId);
4686
+ const overrideLanguage = localeByContactId.get(row.contactId);
4687
+ if (overrideLanguage) {
4688
+ row.language = overrideLanguage;
4689
+ row.marketSegment = overrideLanguage === "de" ? "dach" : "non-dach";
4690
+ }
4691
+ }
4692
+ }
4693
+ const missingContactIds = contactIds.filter((id) => !foundContactIds.has(id));
4694
+ const pack = buildDeelOutreachPack("global", normalizedRows);
4695
+ const enrichedAllPath = path.join(options.outDir, `${baseSlug}-enriched-all.json`);
4696
+ const enrichedDePath = path.join(options.outDir, `${baseSlug}-enriched-de.json`);
4697
+ const enrichedEnPath = path.join(options.outDir, `${baseSlug}-enriched-en.json`);
4698
+ await writeJsonFile(enrichedAllPath, normalizedRows);
4699
+ await writeJsonFile(enrichedDePath, pack.locales.de);
4700
+ await writeJsonFile(enrichedEnPath, pack.locales.en);
4701
+ const syncResults = [];
4702
+ const routes = [
4703
+ {
4704
+ locale: "de",
4705
+ campaignId: campaignIdDe,
4706
+ leads: pack.locales.de
4707
+ },
4708
+ {
4709
+ locale: "en",
4710
+ campaignId: campaignIdEn,
4711
+ leads: pack.locales.en
4712
+ }
4713
+ ];
4714
+ for (const route of routes) {
4715
+ if (!route.campaignId || route.leads.length === 0) {
4716
+ continue;
4717
+ }
4718
+ const result = await syncProvider.sync("instantly", route.leads, {
4719
+ apply: applySync,
4720
+ instantlyCampaignId: route.campaignId,
4721
+ allowDuplicates
4722
+ });
4723
+ syncResults.push({
4724
+ locale: route.locale,
4725
+ campaignId: route.campaignId,
4726
+ synced: result.synced,
4727
+ skipped: result.skipped ?? 0,
4728
+ dryRun: result.dryRun,
4729
+ provider: result.provider ?? "instantly"
4730
+ });
4731
+ }
4732
+ enrichmentSummary = {
4733
+ contactIds: contactIds.length,
4734
+ enrichedRows: normalizedRows.length,
4735
+ missingContactIds,
4736
+ files: {
4737
+ all: enrichedAllPath,
4738
+ de: enrichedDePath,
4739
+ en: enrichedEnPath
4740
+ },
4741
+ syncResults
4742
+ };
4743
+ }
4744
+ }
3495
4745
  const payload = {
3496
4746
  status: "ok",
3497
4747
  vendor: "deel",
@@ -3549,7 +4799,8 @@ program
3549
4799
  enCsv: enCsvPath,
3550
4800
  summary: summaryPath,
3551
4801
  samples: samplesPath
3552
- }
4802
+ },
4803
+ enrichment: enrichmentSummary
3553
4804
  };
3554
4805
  await writeJsonFile(summaryPath, payload);
3555
4806
  await writeJsonFile(samplesPath, samples);
@@ -3557,7 +4808,8 @@ program
3557
4808
  });
3558
4809
  program
3559
4810
  .command("salesnav:crawl")
3560
- .description("Adaptively split broad LinkedIn Sales Navigator people searches into exportable slices and store every finished slice through Salesprompter.")
4811
+ .alias("search:run")
4812
+ .description("Run a saved people search, split broad result sets when needed, and store the finished output.")
3561
4813
  .option("--query-url <url>", "Base LinkedIn Sales Navigator people search URL")
3562
4814
  .option("--job-id <id>", "Resume an existing crawl job by id")
3563
4815
  .option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
@@ -3590,6 +4842,7 @@ program
3590
4842
  const idlePollSeconds = z.coerce.number().int().min(0).max(300).parse(options.idlePollSeconds);
3591
4843
  const idleMaxPolls = z.coerce.number().int().min(0).max(10000).parse(options.idleMaxPolls);
3592
4844
  const parallelExports = z.coerce.number().int().min(1).max(10).parse(options.parallelExports);
4845
+ const phantomLaneLimit = resolveSalesNavigatorPhantomLaneLimit(process.env);
3593
4846
  const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
3594
4847
  const logger = await createWorkflowLogger({
3595
4848
  logPath: options.logPath ?? buildSalesNavigatorCrawlLogPath(jobId ?? queryUrl ?? "salesnav-crawl")
@@ -3609,6 +4862,7 @@ program
3609
4862
  idlePollSeconds,
3610
4863
  idleMaxPolls,
3611
4864
  parallelExports,
4865
+ phantomLaneLimit,
3612
4866
  dryRun: effectiveDryRun
3613
4867
  });
3614
4868
  if (effectiveDryRun) {
@@ -3672,6 +4926,13 @@ program
3672
4926
  throw new Error("Provide exactly one of --query-url or --job-id.");
3673
4927
  }
3674
4928
  let session = await requireAuthSession();
4929
+ const sessionOrgId = resolveSessionOrgId(session);
4930
+ if (sessionOrgId) {
4931
+ const eventStore = await createSalesNavigatorCrawlEventStore({
4932
+ orgId: sessionOrgId
4933
+ });
4934
+ logger.setEventStore(eventStore);
4935
+ }
3675
4936
  let createResult = null;
3676
4937
  let resolvedJobId = jobId ?? null;
3677
4938
  if (queryUrl) {
@@ -3799,23 +5060,42 @@ program
3799
5060
  });
3800
5061
  program
3801
5062
  .command("salesnav:crawl:status")
3802
- .description("Return the current status of a durable Sales Navigator crawl job.")
5063
+ .alias("search:status")
5064
+ .description("Return the current status of a background people-search job.")
3803
5065
  .requiredOption("--job-id <id>", "Sales Navigator crawl job id")
5066
+ .option("--events-limit <number>", "How many recent persisted crawl events to include", "25")
3804
5067
  .action(async (options) => {
3805
5068
  const jobId = z.string().uuid().parse(options.jobId);
5069
+ const eventsLimit = z.coerce.number().int().min(0).max(200).parse(options.eventsLimit);
3806
5070
  let session = await requireAuthSession();
3807
5071
  const status = await getSalesNavigatorCrawlStatus(session, jobId);
3808
5072
  session = status.session;
3809
- void session;
5073
+ const sessionOrgId = resolveSessionOrgId(session);
5074
+ const eventStore = sessionOrgId
5075
+ ? await createSalesNavigatorCrawlEventStore({
5076
+ orgId: sessionOrgId
5077
+ })
5078
+ : null;
5079
+ let recentEvents = [];
5080
+ if (eventsLimit > 0 && eventStore) {
5081
+ try {
5082
+ recentEvents = await eventStore.listRecentForJob(jobId, eventsLimit);
5083
+ }
5084
+ catch {
5085
+ recentEvents = [];
5086
+ }
5087
+ }
3810
5088
  printOutput({
3811
5089
  status: "ok",
3812
5090
  jobId,
3813
- job: status.value.job
5091
+ job: status.value.job,
5092
+ recentEvents
3814
5093
  });
3815
5094
  });
3816
5095
  program
3817
5096
  .command("salesnav:export")
3818
- .description("Apply the default Sales Navigator HR slice filters to one or more people-search URLs, then export and store the results through Salesprompter.")
5097
+ .alias("search:export")
5098
+ .description("Apply the default people-search filters to one or more search URLs, then export and store the results.")
3819
5099
  .requiredOption("--query-url <url>", "Base LinkedIn Sales Navigator people search URL", collectStringOptionValue, [])
3820
5100
  .option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
3821
5101
  .option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
@@ -3876,6 +5156,71 @@ program
3876
5156
  }
3877
5157
  printOutput(payload);
3878
5158
  });
5159
+ program
5160
+ .command("salesnav:count")
5161
+ .alias("search:count")
5162
+ .description("Estimate how many results are available for a people-search URL using a minimal probe.")
5163
+ .requiredOption("--query-url <url>", "LinkedIn Sales Navigator people search URL")
5164
+ .option("--max-results-per-search <number>", "Maximum results allowed for the probe", "2500")
5165
+ .option("--number-of-profiles <number>", "Profiles to scrape for the probe run", "1")
5166
+ .option("--slice-preset <name>", "Slice preset label stored with the probe run", "count-probe")
5167
+ .option("--out <path>", "Optional local JSON output path")
5168
+ .action(async (options) => {
5169
+ const queryUrl = z.string().url().parse(options.queryUrl);
5170
+ const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
5171
+ const numberOfProfiles = z.coerce.number().int().min(1).max(25).parse(options.numberOfProfiles);
5172
+ const session = await requireAuthSession();
5173
+ const payloadBase = {
5174
+ sourceQueryUrl: queryUrl,
5175
+ slicedQueryUrl: queryUrl,
5176
+ appliedFilters: [],
5177
+ maxResultsPerSearch,
5178
+ numberOfProfiles,
5179
+ slicePreset: options.slicePreset,
5180
+ rawPayload: {
5181
+ workflow: "salesnav:count",
5182
+ sourceQueryUrl: queryUrl,
5183
+ slicedQueryUrl: queryUrl,
5184
+ probeProfiles: numberOfProfiles
5185
+ }
5186
+ };
5187
+ try {
5188
+ const result = await runSalesNavigatorExport(session, payloadBase);
5189
+ const payload = {
5190
+ status: "ok",
5191
+ queryUrl,
5192
+ estimatedTotalResults: result.totalResults ?? null,
5193
+ probeProfiles: numberOfProfiles,
5194
+ runId: result.runId,
5195
+ agentId: result.agentId,
5196
+ containerId: result.containerId,
5197
+ resultJsonUrl: result.resultJsonUrl ?? null,
5198
+ resultCsvUrl: result.resultCsvUrl ?? null
5199
+ };
5200
+ if (options.out) {
5201
+ await writeJsonFile(options.out, payload);
5202
+ }
5203
+ printOutput(payload);
5204
+ return;
5205
+ }
5206
+ catch (error) {
5207
+ if (error instanceof SalesNavigatorSliceTooBroadError) {
5208
+ const payload = {
5209
+ status: "ok",
5210
+ queryUrl,
5211
+ estimatedTotalResults: error.totalResults ?? null,
5212
+ tooBroad: true,
5213
+ probeProfiles: numberOfProfiles
5214
+ };
5215
+ if (options.out) {
5216
+ await writeJsonFile(options.out, payload);
5217
+ }
5218
+ printOutput(payload);
5219
+ return;
5220
+ }
5221
+ throw error;
5222
+ }
5223
+ });
3879
5224
  program
3880
5225
  .command("leads:lookup:bq")
3881
5226
  .description("Build a BigQuery lead lookup from an ICP and optionally execute it with the bq CLI.")
@@ -3980,7 +5325,7 @@ program
3980
5325
  program
3981
5326
  .command("leadlists:direct-export:bq")
3982
5327
  .description("Export upstream leads directly from leadLists -> linkedin_contacts -> linkedin_companies and segment them.")
3983
- .requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
5328
+ .requiredOption("--vendor <vendor>", "Vendor template name")
3984
5329
  .option("--market <market>", "global|europe|dach", "dach")
3985
5330
  .option("--limit <number>", "Max rows to export", "20000")
3986
5331
  .requiredOption("--out-dir <path>", "Output directory for raw and segmented files")
@@ -4026,6 +5371,7 @@ program
4026
5371
  });
4027
5372
  program
4028
5373
  .command("leadlists:deel-outreach:bq")
5374
+ .alias("leadlists:vendor-outreach:bq")
4029
5375
  .description("Build Instantly-ready Deel outreach batches from leadPool_new with lead-list provenance, split into German vs English.")
4030
5376
  .option("--market <market>", "global|europe|dach", "global")
4031
5377
  .option("--limit <number>", "Max rows to export", "200000")
@@ -4115,7 +5461,7 @@ program
4115
5461
  program
4116
5462
  .command("leadlists:funnel:bq")
4117
5463
  .description("Build an upstream lead-list funnel report for a vendor/market.")
4118
- .requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
5464
+ .requiredOption("--vendor <vendor>", "Vendor template name")
4119
5465
  .option("--market <market>", "global|europe|dach", "dach")
4120
5466
  .requiredOption("--out <path>", "Output report path")
4121
5467
  .action(async (options) => {