salesprompter-cli 0.1.22 → 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
@@ -14,11 +14,14 @@ import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWi
14
14
  import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
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
+ import { buildDeelSalesNavCsvHeader, buildDeelSalesNavCsvLines, isDeelRelevantSalesNavTitle, normalizeDeelSalesNavRow } from "./deel-salesnav.js";
18
+ import { buildDeelLeadPoolContactSql, buildDeelOutreachExportSql, buildDeelOutreachPack, normalizeDeelOutreachRows } from "./deel-outreach.js";
17
19
  import { buildDirectPathLeadExportSql, normalizeDirectPathRows, segmentDirectPathRows } from "./direct-path.js";
18
20
  import { AccountLeadProvider, DryRunSyncProvider, HeuristicCompanyProvider, HeuristicEnrichmentProvider, HeuristicPeopleSearchProvider, HeuristicScoringProvider, RoutedSyncProvider } from "./engine.js";
19
21
  import { analyzeHistoricalQueries } from "./historical-queries.js";
20
22
  import { buildHistoricalVendorIcp, buildVendorIcp } from "./icp-templates.js";
21
23
  import { InstantlySyncProvider } from "./instantly.js";
24
+ import { backfillLinkedInCompanies } from "./linkedin-companies.js";
22
25
  import { crawlLinkedInProductCategory } from "./linkedin-products.js";
23
26
  import { claimValidatedSalesNavigatorSessionCookieForCli } from "./linkedin-session.js";
24
27
  import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
@@ -54,6 +57,27 @@ const LinkedInProductIngestResponseSchema = z.object({
54
57
  upserted: z.number().int().nonnegative(),
55
58
  totalInCatalog: z.number().int().nonnegative().optional()
56
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
+ });
57
81
  const SalesNavigatorLaunchDiagnosticsSchema = z.object({
58
82
  orderedCandidateAgentIds: z.array(z.string().min(1)),
59
83
  runningAgentIds: z.array(z.string().min(1)),
@@ -212,6 +236,40 @@ const SalesNavigatorCrawlReportResponseSchema = z.object({
212
236
  status: z.literal("ok"),
213
237
  job: SalesNavigatorCrawlJobSummarySchema
214
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
+ ]);
215
273
  function printOutput(value) {
216
274
  if (runtimeOutputOptions.quiet) {
217
275
  return;
@@ -225,6 +283,13 @@ function writeProgress(message) {
225
283
  }
226
284
  process.stderr.write(`${message}\n`);
227
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
+ }
228
293
  function applyGlobalOutputOptions(actionCommand) {
229
294
  const globalOptions = actionCommand.optsWithGlobals();
230
295
  runtimeOutputOptions.json = Boolean(globalOptions.json);
@@ -329,6 +394,27 @@ function canPromptForInteractiveLogin() {
329
394
  }
330
395
  return Boolean(process.stdin.isTTY && process.stderr.isTTY);
331
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
+ }
332
418
  async function ensureInteractiveAuthSession(apiUrl) {
333
419
  if (!canPromptForInteractiveLogin()) {
334
420
  return;
@@ -345,6 +431,523 @@ function shellQuote(value) {
345
431
  }
346
432
  return `'${value.replaceAll("'", "'\\''")}'`;
347
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
+ }
348
951
  function buildCommandLine(args) {
349
952
  return args.map((arg) => shellQuote(arg)).join(" ");
350
953
  }
@@ -359,6 +962,24 @@ function slugify(value) {
359
962
  .replace(/^-+|-+$/g, "")
360
963
  .replace(/-{2,}/g, "-");
361
964
  }
965
+ function resolveSalesNavigatorSupabaseConfig(env = process.env) {
966
+ const supabaseUrl = env.SALESPROMPTER_SUPABASE_URL?.trim() || env.NEXT_PUBLIC_SUPABASE_URL?.trim() || "";
967
+ const supabaseServiceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY?.trim() || "";
968
+ const missing = [];
969
+ if (supabaseUrl.length === 0) {
970
+ missing.push("SALESPROMPTER_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL");
971
+ }
972
+ if (supabaseServiceRoleKey.length === 0) {
973
+ missing.push("SUPABASE_SERVICE_ROLE_KEY");
974
+ }
975
+ if (missing.length > 0) {
976
+ throw new Error(`Missing required environment variables for Sales Navigator Supabase export: ${missing.join(", ")}`);
977
+ }
978
+ return {
979
+ supabaseUrl,
980
+ supabaseServiceRoleKey
981
+ };
982
+ }
362
983
  function normalizeDomainInput(value) {
363
984
  return value
364
985
  .trim()
@@ -441,6 +1062,26 @@ function parseCompanyReference(value) {
441
1062
  })
442
1063
  };
443
1064
  }
1065
+ function chunkArray(values, size) {
1066
+ const chunks = [];
1067
+ for (let index = 0; index < values.length; index += size) {
1068
+ chunks.push(values.slice(index, index + size));
1069
+ }
1070
+ return chunks;
1071
+ }
1072
+ function extractSalesNavContactId(url) {
1073
+ if (!url) {
1074
+ return null;
1075
+ }
1076
+ try {
1077
+ const parsed = new URL(url);
1078
+ const segments = parsed.pathname.split("/").filter((segment) => segment.length > 0);
1079
+ return segments.length > 0 ? segments[segments.length - 1] : null;
1080
+ }
1081
+ catch {
1082
+ return null;
1083
+ }
1084
+ }
444
1085
  function writeWizardLine(message = "") {
445
1086
  process.stdout.write(`${message}\n`);
446
1087
  }
@@ -461,6 +1102,10 @@ function getOrgLabel(session) {
461
1102
  }
462
1103
  return label;
463
1104
  }
1105
+ function resolveSessionOrgId(session) {
1106
+ const orgId = session.user.orgId?.trim();
1107
+ return orgId && orgId.length > 0 ? orgId : null;
1108
+ }
464
1109
  function writeSessionSummary(session) {
465
1110
  const identity = session.user.name?.trim()
466
1111
  ? `${session.user.name} (${session.user.email})`
@@ -702,6 +1347,50 @@ async function ensureWizardSession(options) {
702
1347
  writeWizardLine();
703
1348
  return result.session;
704
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
+ }
705
1394
  async function fetchWorkspaceLeadSearch(session, requestBody) {
706
1395
  const response = await fetch(`${session.apiBaseUrl}/api/cli/leads/search`, {
707
1396
  method: "POST",
@@ -762,10 +1451,14 @@ function decodeSalesNavigatorQueryParam(url) {
762
1451
  async function createWorkflowLogger(options) {
763
1452
  const traceId = options.traceId ?? buildWorkflowTraceId("salesprompter-cli");
764
1453
  const logPath = options.logPath;
1454
+ let eventStore = options.eventStore ?? null;
765
1455
  await mkdir(path.dirname(logPath), { recursive: true });
766
1456
  return {
767
1457
  traceId,
768
1458
  logPath,
1459
+ setEventStore: (nextEventStore) => {
1460
+ eventStore = nextEventStore;
1461
+ },
769
1462
  log: async (event, metadata = {}) => {
770
1463
  const entry = {
771
1464
  timestamp: new Date().toISOString(),
@@ -774,10 +1467,111 @@ async function createWorkflowLogger(options) {
774
1467
  metadata
775
1468
  };
776
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
+ }
777
1486
  writeProgress(`[${entry.timestamp}] ${event}`);
778
1487
  }
779
1488
  };
780
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
+ }
781
1575
  function summarizeSalesNavigatorQuery(url, appliedFilters) {
782
1576
  return {
783
1577
  url,
@@ -1001,6 +1795,13 @@ async function runSalesNavigatorFromProductCategoryWorkflow(options) {
1001
1795
  return { outPath, payload };
1002
1796
  }
1003
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
+ }
1004
1805
  let uploaded = null;
1005
1806
  if (!options.skipProductUpload) {
1006
1807
  await logger.log("linkedin.catalog.upload.started", {
@@ -1115,7 +1916,57 @@ async function runSalesNavigatorFromProductCategoryWorkflow(options) {
1115
1916
  summary: crawlSummary
1116
1917
  });
1117
1918
  }
1118
- 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
+ }
1119
1970
  const payload = {
1120
1971
  status: "ok",
1121
1972
  dryRun: false,
@@ -1243,6 +2094,26 @@ async function uploadLinkedInProductsCatalog(session, payload, batchSize = 100,
1243
2094
  }
1244
2095
  return { imported, upserted };
1245
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
+ }
1246
2117
  function serializeSalesNavigatorFiltersForApi(filters) {
1247
2118
  return filters.map((filter) => ({
1248
2119
  type: filter.type,
@@ -1486,6 +2357,55 @@ function isSalesNavigatorAgentBusyError(error) {
1486
2357
  const message = error instanceof Error ? error.message : String(error);
1487
2358
  return /parallel executions limit/i.test(message);
1488
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
+ }
1489
2409
  function isSalesNavigatorSessionError(error) {
1490
2410
  if (error instanceof SalesNavigatorExportRequestError) {
1491
2411
  if (error.errorCode === "invalid_session") {
@@ -1496,7 +2416,7 @@ function isSalesNavigatorSessionError(error) {
1496
2416
  }
1497
2417
  }
1498
2418
  const message = error instanceof Error ? error.message : String(error);
1499
- 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);
1500
2420
  }
1501
2421
  function isSalesNavigatorResultArtifactError(error) {
1502
2422
  if (error instanceof SalesNavigatorExportRequestError && error.errorCode === "phantombuster_result_invalid") {
@@ -2125,7 +3045,23 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
2125
3045
  let job = null;
2126
3046
  let idlePollCount = 0;
2127
3047
  let lastOutcome = null;
2128
- 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
+ }
2129
3065
  const inFlight = new Map();
2130
3066
  let nextSlot = 0;
2131
3067
  let noMoreClaimableWork = false;
@@ -2395,13 +3331,13 @@ async function runProductMarketWizard(rl) {
2395
3331
  writeWizardLine(` ${buildCommandLine(commandArgs)}`);
2396
3332
  }
2397
3333
  async function runVendorShortcutWizard(rl) {
2398
- 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.");
2399
3335
  const reference = parseCompanyReference(await promptText(rl, "Which company shortcut should I use?", {
2400
3336
  required: true
2401
3337
  }));
2402
3338
  writeWizardLine();
2403
3339
  if (reference.vendorTemplate !== "deel") {
2404
- 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.");
2405
3341
  }
2406
3342
  const market = await promptChoice(rl, "Where do you want to search?", [
2407
3343
  { value: "dach", label: "DACH", description: "Germany, Austria, Switzerland" },
@@ -2538,14 +3474,14 @@ async function runWizard(options) {
2538
3474
  },
2539
3475
  {
2540
3476
  value: "reference-company",
2541
- label: "Use the built-in Deel shortcut",
2542
- description: "Generate the saved Deel ICP and search workspace leads",
2543
- 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"]
2544
3480
  },
2545
3481
  {
2546
3482
  value: "target-company",
2547
3483
  label: "Find people at a specific company",
2548
- description: "Example: find people at deel.com",
3484
+ description: "Example: find people at company.com",
2549
3485
  aliases: ["target company", "company", "find people", "people at a company", "lead generation"]
2550
3486
  },
2551
3487
  {
@@ -2710,10 +3646,33 @@ async function fetchHistoricalQueryRows(tables) {
2710
3646
  }
2711
3647
  program
2712
3648
  .name("salesprompter")
2713
- .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.")
2714
3650
  .version(packageVersion)
2715
3651
  .option("--json", "Emit compact machine-readable JSON output", false)
2716
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
+ `);
2717
3676
  program
2718
3677
  .command("auth:login")
2719
3678
  .description("Authenticate CLI with a Salesprompter app token, or device flow if the app supports it.")
@@ -2766,6 +3725,125 @@ program
2766
3725
  createdAt: verifiedSession.createdAt
2767
3726
  });
2768
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
+ });
2769
3847
  program
2770
3848
  .command("auth:logout")
2771
3849
  .description("Remove local CLI auth session.")
@@ -2776,7 +3854,10 @@ program
2776
3854
  program.hook("preAction", async (_thisCommand, actionCommand) => {
2777
3855
  applyGlobalOutputOptions(actionCommand);
2778
3856
  const commandName = actionCommand.name();
2779
- if (commandName.startsWith("auth:") || commandName === "wizard") {
3857
+ if (commandName.startsWith("auth:") ||
3858
+ commandName === "wizard" ||
3859
+ commandName === "llm:ready" ||
3860
+ commandName === "contacts:find-linkedin-urls") {
2780
3861
  return;
2781
3862
  }
2782
3863
  const commandOptions = actionCommand.opts();
@@ -2789,6 +3870,11 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
2789
3870
  if (shouldBypassAuth()) {
2790
3871
  return;
2791
3872
  }
3873
+ const envToken = resolveNonInteractiveAuthToken(process.env);
3874
+ if (envToken) {
3875
+ await loginWithToken(envToken, process.env.SALESPROMPTER_API_BASE_URL?.trim());
3876
+ return;
3877
+ }
2792
3878
  try {
2793
3879
  const session = await requireAuthSession();
2794
3880
  if (session.expiresAt !== undefined && Date.now() >= Date.parse(session.expiresAt)) {
@@ -2796,7 +3882,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
2796
3882
  await ensureInteractiveAuthSession(session.apiBaseUrl);
2797
3883
  return;
2798
3884
  }
2799
- 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.");
2800
3886
  }
2801
3887
  }
2802
3888
  catch (error) {
@@ -2812,7 +3898,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
2812
3898
  program
2813
3899
  .command("account:resolve")
2814
3900
  .description("Resolve a target company into a normalized account profile.")
2815
- .requiredOption("--domain <domain>", "Company domain like deel.com")
3901
+ .requiredOption("--domain <domain>", "Company domain like company.com")
2816
3902
  .option("--company-name <name>", "Optional company name override")
2817
3903
  .option("--icp <path>", "Optional path to ICP JSON for industry/region/title hints")
2818
3904
  .requiredOption("--out <path>", "Output file path")
@@ -2871,7 +3957,7 @@ program
2871
3957
  program
2872
3958
  .command("icp:vendor")
2873
3959
  .description("Create a vendor-specific ICP template.")
2874
- .requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
3960
+ .requiredOption("--vendor <vendor>", "Vendor template name")
2875
3961
  .option("--market <market>", "global|europe|dach", "dach")
2876
3962
  .option("--out <path>", "Optional output file path")
2877
3963
  .action(async (options) => {
@@ -2886,7 +3972,7 @@ program
2886
3972
  program
2887
3973
  .command("icp:from-historical-queries:bq")
2888
3974
  .description("Build a vendor ICP from historical BigQuery query patterns.")
2889
- .requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
3975
+ .requiredOption("--vendor <vendor>", "Vendor template name")
2890
3976
  .option("--market <market>", "global|europe|dach", "dach")
2891
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")
2892
3978
  .option("--search-kind <kind>", "all|people|sales-people|sales-company", "sales-people")
@@ -2926,7 +4012,7 @@ program
2926
4012
  .description("Generate leads for a target account or from fallback seeds.")
2927
4013
  .requiredOption("--icp <path>", "Path to ICP JSON")
2928
4014
  .option("--count <number>", "Number of leads to generate", "10")
2929
- .option("--domain <domain>", "Target a specific company domain like deel.com")
4015
+ .option("--domain <domain>", "Target a specific company domain like company.com")
2930
4016
  .option("--company-domain <domain>", "Deprecated alias for --domain")
2931
4017
  .option("--company-name <name>", "Optional company name override for a targeted domain")
2932
4018
  .requiredOption("--out <path>", "Output file path")
@@ -2975,6 +4061,51 @@ program
2975
4061
  await writeJsonFile(options.out, scored);
2976
4062
  printOutput({ status: "ok", scored: scored.length, out: options.out });
2977
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
+ });
2978
4109
  program
2979
4110
  .command("sync:crm")
2980
4111
  .description("Dry-run sync scored leads into a CRM target.")
@@ -3004,9 +4135,56 @@ program
3004
4135
  });
3005
4136
  printOutput({ status: "ok", ...result });
3006
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
+ });
3007
4184
  program
3008
4185
  .command("linkedin-products:scrape")
3009
- .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.")
3010
4188
  .requiredOption("--input <value>", "Company domain, LinkedIn company URL, LinkedIn product URL, LinkedIn category URL, or LinkedIn product search URL")
3011
4189
  .option("--max-pages <number>", "Maximum LinkedIn category pages to fetch", "25")
3012
4190
  .option("--limit <number>", "Optional cap on the number of products to keep")
@@ -3053,7 +4231,8 @@ program
3053
4231
  });
3054
4232
  program
3055
4233
  .command("salesnav:from-product-category")
3056
- .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.")
3057
4236
  .requiredOption("--input <value>", "Company domain, LinkedIn company URL, LinkedIn product URL, LinkedIn category URL, or LinkedIn product search URL")
3058
4237
  .option("--max-pages <number>", "Maximum LinkedIn category pages to fetch", "25")
3059
4238
  .option("--product-limit <number>", "Optional cap on the number of LinkedIn products to inspect")
@@ -3264,9 +4443,373 @@ program
3264
4443
  }
3265
4444
  printOutput(payload);
3266
4445
  });
4446
+ program
4447
+ .command("salesnav:deel-locale-export")
4448
+ .alias("salesnav:vendor-locale-export")
4449
+ .description("Export the Supabase Sales Navigator Deel corpus into German-vs-English outreach backlog files.")
4450
+ .option("--org-id <id>", "Workspace org id. Defaults to the active CLI org.")
4451
+ .option("--limit <number>", "Maximum number of Supabase rows to process", "250000")
4452
+ .option("--page-size <number>", "Supabase page size per request", "1000")
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)
4461
+ .requiredOption("--out-dir <path>", "Output directory for summary and locale CSV files")
4462
+ .action(async (options) => {
4463
+ const limit = z.coerce.number().int().min(1).max(500000).parse(options.limit);
4464
+ const pageSize = z.coerce.number().int().min(1).max(1000).parse(options.pageSize);
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);
4473
+ let sessionOrgId = null;
4474
+ if (!shouldBypassAuth()) {
4475
+ const session = await requireAuthSession();
4476
+ sessionOrgId = session.user.orgId ?? null;
4477
+ }
4478
+ const orgId = resolveSalesNavigatorHistoricalBackfillOrgId({
4479
+ explicitOrgId: options.orgId,
4480
+ env: process.env,
4481
+ sessionOrgId
4482
+ });
4483
+ const config = resolveSalesNavigatorSupabaseConfig(process.env);
4484
+ const supabase = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
4485
+ auth: { persistSession: false }
4486
+ });
4487
+ const countResponse = await supabase
4488
+ .from("linkedin_sales_nav_people")
4489
+ .select("id", { count: "exact", head: true })
4490
+ .eq("org_id", orgId);
4491
+ if (countResponse.error) {
4492
+ throw new Error(`Failed to count linkedin_sales_nav_people rows: ${countResponse.error.message}`);
4493
+ }
4494
+ const totalInOrg = countResponse.count ?? 0;
4495
+ const totalToProcess = Math.min(totalInOrg, limit);
4496
+ const baseSlug = `deel-salesnav-${slugify(orgId) || "workspace"}`;
4497
+ const deCsvPath = path.join(options.outDir, `${baseSlug}-de.csv`);
4498
+ const enCsvPath = path.join(options.outDir, `${baseSlug}-en.csv`);
4499
+ const summaryPath = path.join(options.outDir, `${baseSlug}-summary.json`);
4500
+ const samplesPath = path.join(options.outDir, `${baseSlug}-samples.json`);
4501
+ await mkdir(options.outDir, { recursive: true });
4502
+ await writeFile(deCsvPath, `${buildDeelSalesNavCsvHeader()}\n`, "utf8");
4503
+ await writeFile(enCsvPath, `${buildDeelSalesNavCsvHeader()}\n`, "utf8");
4504
+ const localeCounts = { de: 0, en: 0 };
4505
+ let titleMatchedCount = 0;
4506
+ let titleFilteredOutCount = 0;
4507
+ const fieldCounts = {
4508
+ firstName: 0,
4509
+ lastName: 0,
4510
+ fullName: 0,
4511
+ companyName: 0,
4512
+ companyNameCleaned: 0,
4513
+ preferredProfileUrl: 0,
4514
+ linkedinProfileUrl: 0,
4515
+ companyLinkedInHandle: 0,
4516
+ location: 0,
4517
+ companyLocation: 0,
4518
+ searchQuery: 0
4519
+ };
4520
+ const signalFieldCounts = {
4521
+ location: 0,
4522
+ companyLocation: 0,
4523
+ searchQuery: 0,
4524
+ none: 0
4525
+ };
4526
+ const titleCounts = new Map();
4527
+ const samples = {
4528
+ de: [],
4529
+ en: []
4530
+ };
4531
+ const keptRows = [];
4532
+ const selectFields = [
4533
+ "id",
4534
+ "org_id",
4535
+ "run_id",
4536
+ "sales_nav_profile_url",
4537
+ "linkedin_profile_url",
4538
+ "default_profile_url",
4539
+ "full_name",
4540
+ "first_name",
4541
+ "last_name",
4542
+ "company_name",
4543
+ "company_url",
4544
+ "regular_company_url",
4545
+ "title",
4546
+ "industry",
4547
+ "location",
4548
+ "company_location",
4549
+ "search_query",
4550
+ "scraped_at"
4551
+ ].join(", ");
4552
+ let processed = 0;
4553
+ let lastSeenId = null;
4554
+ while (processed < totalToProcess) {
4555
+ let query = supabase
4556
+ .from("linkedin_sales_nav_people")
4557
+ .select(selectFields)
4558
+ .eq("org_id", orgId)
4559
+ .order("id", { ascending: true })
4560
+ .limit(Math.min(pageSize, totalToProcess - processed));
4561
+ if (lastSeenId) {
4562
+ query = query.gt("id", lastSeenId);
4563
+ }
4564
+ const response = await query;
4565
+ if (response.error) {
4566
+ throw new Error(`Failed to read linkedin_sales_nav_people rows after ${lastSeenId ?? "start"}: ${response.error.message}`);
4567
+ }
4568
+ const pageRows = (response.data ?? []);
4569
+ if (pageRows.length === 0) {
4570
+ break;
4571
+ }
4572
+ const relevantRows = pageRows.filter((row) => {
4573
+ if (titleFilter === "all") {
4574
+ titleMatchedCount += 1;
4575
+ return true;
4576
+ }
4577
+ const matches = isDeelRelevantSalesNavTitle(row.title);
4578
+ if (matches) {
4579
+ titleMatchedCount += 1;
4580
+ return true;
4581
+ }
4582
+ titleFilteredOutCount += 1;
4583
+ return false;
4584
+ });
4585
+ const preparedRows = relevantRows.map((row) => normalizeDeelSalesNavRow(row));
4586
+ const deRows = preparedRows.filter((row) => row.language === "de");
4587
+ const enRows = preparedRows.filter((row) => row.language === "en");
4588
+ keptRows.push(...preparedRows);
4589
+ if (deRows.length > 0) {
4590
+ await appendFile(deCsvPath, `${buildDeelSalesNavCsvLines(deRows)}\n`, "utf8");
4591
+ }
4592
+ if (enRows.length > 0) {
4593
+ await appendFile(enCsvPath, `${buildDeelSalesNavCsvLines(enRows)}\n`, "utf8");
4594
+ }
4595
+ for (const row of preparedRows) {
4596
+ localeCounts[row.language] += 1;
4597
+ if (row.signalFields.length === 0) {
4598
+ signalFieldCounts.none += 1;
4599
+ }
4600
+ else {
4601
+ for (const field of row.signalFields) {
4602
+ if (field === "location") {
4603
+ signalFieldCounts.location += 1;
4604
+ }
4605
+ else if (field === "companyLocation") {
4606
+ signalFieldCounts.companyLocation += 1;
4607
+ }
4608
+ else if (field === "searchQuery") {
4609
+ signalFieldCounts.searchQuery += 1;
4610
+ }
4611
+ }
4612
+ }
4613
+ if (row.firstName)
4614
+ fieldCounts.firstName += 1;
4615
+ if (row.lastName)
4616
+ fieldCounts.lastName += 1;
4617
+ if (row.fullName)
4618
+ fieldCounts.fullName += 1;
4619
+ if (row.companyName)
4620
+ fieldCounts.companyName += 1;
4621
+ if (row.companyNameCleaned)
4622
+ fieldCounts.companyNameCleaned += 1;
4623
+ if (row.preferredProfileUrl)
4624
+ fieldCounts.preferredProfileUrl += 1;
4625
+ if (row.linkedinProfileUrl)
4626
+ fieldCounts.linkedinProfileUrl += 1;
4627
+ if (row.companyLinkedInHandle)
4628
+ fieldCounts.companyLinkedInHandle += 1;
4629
+ if (row.location)
4630
+ fieldCounts.location += 1;
4631
+ if (row.companyLocation)
4632
+ fieldCounts.companyLocation += 1;
4633
+ if (row.searchQuery)
4634
+ fieldCounts.searchQuery += 1;
4635
+ if (row.title) {
4636
+ titleCounts.set(row.title, (titleCounts.get(row.title) ?? 0) + 1);
4637
+ }
4638
+ if (samples[row.language].length < 25) {
4639
+ samples[row.language].push(row);
4640
+ }
4641
+ }
4642
+ processed += pageRows.length;
4643
+ lastSeenId = pageRows[pageRows.length - 1]?.id ?? lastSeenId;
4644
+ const completedPages = Math.ceil(processed / pageSize);
4645
+ if (completedPages === 1 || processed === totalToProcess || completedPages % 10 === 0) {
4646
+ writeProgress(`Processed ${processed}/${totalToProcess} Deel Sales Navigator rows for org ${orgId}; kept ${titleMatchedCount} after ${titleFilter} title filtering.`);
4647
+ }
4648
+ }
4649
+ const keptTotal = localeCounts.de + localeCounts.en;
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
+ }
4745
+ const payload = {
4746
+ status: "ok",
4747
+ vendor: "deel",
4748
+ source: "salesnav-supabase",
4749
+ recommendedRouting: "separate-campaigns-by-language",
4750
+ orgId,
4751
+ totalInOrg,
4752
+ scanned: processed,
4753
+ titleFilter,
4754
+ keptAfterTitleFilter: keptTotal,
4755
+ titleMatchedCount,
4756
+ titleFilteredOutCount,
4757
+ truncatedByLimit: totalInOrg > processed,
4758
+ localeCounts,
4759
+ localePercentages: {
4760
+ de: percentage(localeCounts.de),
4761
+ en: percentage(localeCounts.en)
4762
+ },
4763
+ fieldCoverage: {
4764
+ firstName: { count: fieldCounts.firstName, percentage: percentage(fieldCounts.firstName) },
4765
+ lastName: { count: fieldCounts.lastName, percentage: percentage(fieldCounts.lastName) },
4766
+ fullName: { count: fieldCounts.fullName, percentage: percentage(fieldCounts.fullName) },
4767
+ companyName: { count: fieldCounts.companyName, percentage: percentage(fieldCounts.companyName) },
4768
+ companyNameCleaned: {
4769
+ count: fieldCounts.companyNameCleaned,
4770
+ percentage: percentage(fieldCounts.companyNameCleaned)
4771
+ },
4772
+ preferredProfileUrl: {
4773
+ count: fieldCounts.preferredProfileUrl,
4774
+ percentage: percentage(fieldCounts.preferredProfileUrl)
4775
+ },
4776
+ linkedinProfileUrl: {
4777
+ count: fieldCounts.linkedinProfileUrl,
4778
+ percentage: percentage(fieldCounts.linkedinProfileUrl)
4779
+ },
4780
+ companyLinkedInHandle: {
4781
+ count: fieldCounts.companyLinkedInHandle,
4782
+ percentage: percentage(fieldCounts.companyLinkedInHandle)
4783
+ },
4784
+ location: { count: fieldCounts.location, percentage: percentage(fieldCounts.location) },
4785
+ companyLocation: {
4786
+ count: fieldCounts.companyLocation,
4787
+ percentage: percentage(fieldCounts.companyLocation)
4788
+ },
4789
+ searchQuery: { count: fieldCounts.searchQuery, percentage: percentage(fieldCounts.searchQuery) }
4790
+ },
4791
+ signalFieldCounts,
4792
+ topTitles: [...titleCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20),
4793
+ campaignRecommendation: {
4794
+ de: "Only rows with clear DACH signals should enter the German Deel campaign.",
4795
+ en: "Route everything else to a separate English Deel campaign. English is the safer fallback for ambiguous locales."
4796
+ },
4797
+ files: {
4798
+ deCsv: deCsvPath,
4799
+ enCsv: enCsvPath,
4800
+ summary: summaryPath,
4801
+ samples: samplesPath
4802
+ },
4803
+ enrichment: enrichmentSummary
4804
+ };
4805
+ await writeJsonFile(summaryPath, payload);
4806
+ await writeJsonFile(samplesPath, samples);
4807
+ printOutput(payload);
4808
+ });
3267
4809
  program
3268
4810
  .command("salesnav:crawl")
3269
- .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.")
3270
4813
  .option("--query-url <url>", "Base LinkedIn Sales Navigator people search URL")
3271
4814
  .option("--job-id <id>", "Resume an existing crawl job by id")
3272
4815
  .option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
@@ -3299,6 +4842,7 @@ program
3299
4842
  const idlePollSeconds = z.coerce.number().int().min(0).max(300).parse(options.idlePollSeconds);
3300
4843
  const idleMaxPolls = z.coerce.number().int().min(0).max(10000).parse(options.idleMaxPolls);
3301
4844
  const parallelExports = z.coerce.number().int().min(1).max(10).parse(options.parallelExports);
4845
+ const phantomLaneLimit = resolveSalesNavigatorPhantomLaneLimit(process.env);
3302
4846
  const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
3303
4847
  const logger = await createWorkflowLogger({
3304
4848
  logPath: options.logPath ?? buildSalesNavigatorCrawlLogPath(jobId ?? queryUrl ?? "salesnav-crawl")
@@ -3318,6 +4862,7 @@ program
3318
4862
  idlePollSeconds,
3319
4863
  idleMaxPolls,
3320
4864
  parallelExports,
4865
+ phantomLaneLimit,
3321
4866
  dryRun: effectiveDryRun
3322
4867
  });
3323
4868
  if (effectiveDryRun) {
@@ -3381,6 +4926,13 @@ program
3381
4926
  throw new Error("Provide exactly one of --query-url or --job-id.");
3382
4927
  }
3383
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
+ }
3384
4936
  let createResult = null;
3385
4937
  let resolvedJobId = jobId ?? null;
3386
4938
  if (queryUrl) {
@@ -3508,23 +5060,42 @@ program
3508
5060
  });
3509
5061
  program
3510
5062
  .command("salesnav:crawl:status")
3511
- .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.")
3512
5065
  .requiredOption("--job-id <id>", "Sales Navigator crawl job id")
5066
+ .option("--events-limit <number>", "How many recent persisted crawl events to include", "25")
3513
5067
  .action(async (options) => {
3514
5068
  const jobId = z.string().uuid().parse(options.jobId);
5069
+ const eventsLimit = z.coerce.number().int().min(0).max(200).parse(options.eventsLimit);
3515
5070
  let session = await requireAuthSession();
3516
5071
  const status = await getSalesNavigatorCrawlStatus(session, jobId);
3517
5072
  session = status.session;
3518
- 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
+ }
3519
5088
  printOutput({
3520
5089
  status: "ok",
3521
5090
  jobId,
3522
- job: status.value.job
5091
+ job: status.value.job,
5092
+ recentEvents
3523
5093
  });
3524
5094
  });
3525
5095
  program
3526
5096
  .command("salesnav:export")
3527
- .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.")
3528
5099
  .requiredOption("--query-url <url>", "Base LinkedIn Sales Navigator people search URL", collectStringOptionValue, [])
3529
5100
  .option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
3530
5101
  .option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
@@ -3585,6 +5156,71 @@ program
3585
5156
  }
3586
5157
  printOutput(payload);
3587
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
+ });
3588
5224
  program
3589
5225
  .command("leads:lookup:bq")
3590
5226
  .description("Build a BigQuery lead lookup from an ICP and optionally execute it with the bq CLI.")
@@ -3689,7 +5325,7 @@ program
3689
5325
  program
3690
5326
  .command("leadlists:direct-export:bq")
3691
5327
  .description("Export upstream leads directly from leadLists -> linkedin_contacts -> linkedin_companies and segment them.")
3692
- .requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
5328
+ .requiredOption("--vendor <vendor>", "Vendor template name")
3693
5329
  .option("--market <market>", "global|europe|dach", "dach")
3694
5330
  .option("--limit <number>", "Max rows to export", "20000")
3695
5331
  .requiredOption("--out-dir <path>", "Output directory for raw and segmented files")
@@ -3733,10 +5369,99 @@ program
3733
5369
  sqlOut: options.sqlOut ?? null
3734
5370
  });
3735
5371
  });
5372
+ program
5373
+ .command("leadlists:deel-outreach:bq")
5374
+ .alias("leadlists:vendor-outreach:bq")
5375
+ .description("Build Instantly-ready Deel outreach batches from leadPool_new with lead-list provenance, split into German vs English.")
5376
+ .option("--market <market>", "global|europe|dach", "global")
5377
+ .option("--limit <number>", "Max rows to export", "200000")
5378
+ .option("--min-email-score <number>", "Minimum email score to keep", "70")
5379
+ .requiredOption("--out-dir <path>", "Output directory for raw rows, packs, and locale batches")
5380
+ .option("--sql-out <path>", "Optional file path for the generated SQL")
5381
+ .option("--campaign-id <id>", "Fallback Instantly campaign id for all locales")
5382
+ .option("--campaign-id-de <id>", "Instantly campaign id for German/DACH leads")
5383
+ .option("--campaign-id-en <id>", "Instantly campaign id for English/non-DACH leads")
5384
+ .option("--apply", "Create leads in Instantly instead of export-only mode", false)
5385
+ .option("--allow-duplicates", "Do not skip emails already present in the Instantly campaign", false)
5386
+ .action(async (options) => {
5387
+ const market = z.enum(["global", "europe", "dach"]).parse(options.market);
5388
+ const limit = z.coerce.number().int().min(1).max(500000).parse(options.limit);
5389
+ const minEmailScore = z.coerce.number().int().min(0).max(100).parse(options.minEmailScore);
5390
+ const sql = buildDeelOutreachExportSql({ market, limit, minEmailScore });
5391
+ if (options.sqlOut) {
5392
+ await writeTextFile(options.sqlOut, `${sql}\n`);
5393
+ }
5394
+ const rows = await runBigQueryRows(sql, { maxRows: limit });
5395
+ const normalizedRows = normalizeDeelOutreachRows(rows);
5396
+ const pack = buildDeelOutreachPack(market, normalizedRows);
5397
+ const baseSlug = `deel-outreach-${market}`;
5398
+ const rawPath = path.join(options.outDir, `${baseSlug}-raw.json`);
5399
+ const packPath = path.join(options.outDir, `${baseSlug}-pack.json`);
5400
+ const allPath = path.join(options.outDir, `${baseSlug}-all.json`);
5401
+ const dePath = path.join(options.outDir, `${baseSlug}-de.json`);
5402
+ const enPath = path.join(options.outDir, `${baseSlug}-en.json`);
5403
+ await writeJsonFile(rawPath, normalizedRows);
5404
+ await writeJsonFile(packPath, pack);
5405
+ await writeJsonFile(allPath, [...pack.locales.de, ...pack.locales.en]);
5406
+ await writeJsonFile(dePath, pack.locales.de);
5407
+ await writeJsonFile(enPath, pack.locales.en);
5408
+ const syncResults = [];
5409
+ const routes = [
5410
+ {
5411
+ locale: "de",
5412
+ campaignId: options.campaignIdDe ?? options.campaignId,
5413
+ leads: pack.locales.de
5414
+ },
5415
+ {
5416
+ locale: "en",
5417
+ campaignId: options.campaignIdEn ?? options.campaignId,
5418
+ leads: pack.locales.en
5419
+ }
5420
+ ];
5421
+ for (const route of routes) {
5422
+ if (!route.campaignId || route.leads.length === 0) {
5423
+ continue;
5424
+ }
5425
+ const result = await syncProvider.sync("instantly", route.leads, {
5426
+ apply: Boolean(options.apply),
5427
+ instantlyCampaignId: route.campaignId,
5428
+ allowDuplicates: Boolean(options.allowDuplicates)
5429
+ });
5430
+ syncResults.push({
5431
+ locale: route.locale,
5432
+ campaignId: route.campaignId,
5433
+ synced: result.synced,
5434
+ skipped: result.skipped ?? 0,
5435
+ dryRun: result.dryRun,
5436
+ provider: result.provider ?? "instantly"
5437
+ });
5438
+ }
5439
+ printOutput({
5440
+ status: "ok",
5441
+ vendor: "deel",
5442
+ market,
5443
+ limit,
5444
+ minEmailScore,
5445
+ rowCount: normalizedRows.length,
5446
+ hitLimit: normalizedRows.length === limit,
5447
+ localeCounts: pack.summary.localeCounts,
5448
+ segmentCounts: pack.summary.segmentCounts,
5449
+ averageEmailScoreByLocale: pack.summary.averageEmailScoreByLocale,
5450
+ recommendedRouting: "separate-campaigns-by-language",
5451
+ outDir: options.outDir,
5452
+ raw: rawPath,
5453
+ pack: packPath,
5454
+ all: allPath,
5455
+ german: dePath,
5456
+ english: enPath,
5457
+ syncResults,
5458
+ sqlOut: options.sqlOut ?? null
5459
+ });
5460
+ });
3736
5461
  program
3737
5462
  .command("leadlists:funnel:bq")
3738
5463
  .description("Build an upstream lead-list funnel report for a vendor/market.")
3739
- .requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
5464
+ .requiredOption("--vendor <vendor>", "Vendor template name")
3740
5465
  .option("--market <market>", "global|europe|dach", "dach")
3741
5466
  .requiredOption("--out <path>", "Output report path")
3742
5467
  .action(async (options) => {