salesprompter-cli 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { access } from "node:fs/promises";
3
+ import { access, appendFile, mkdir } from "node:fs/promises";
4
4
  import { createRequire } from "node:module";
5
5
  import path from "node:path";
6
6
  import { emitKeypressEvents } from "node:readline";
7
7
  import { createInterface } from "node:readline/promises";
8
8
  import { setTimeout as delay } from "node:timers/promises";
9
+ import { createClient } from "@supabase/supabase-js";
9
10
  import { Command } from "commander";
10
11
  import { z } from "zod";
11
12
  import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, requireAuthSession, shouldBypassAuth, verifySession } from "./auth.js";
@@ -20,7 +21,8 @@ import { InstantlySyncProvider } from "./instantly.js";
20
21
  import { crawlLinkedInProductCategory } from "./linkedin-products.js";
21
22
  import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
22
23
  import { readJsonFile, splitCsv, writeJsonFile, writeTextFile } from "./io.js";
23
- import { buildSalesNavigatorCrawlPreview, createSalesNavigatorCrawlSeed, DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS, buildSalesNavigatorPeopleSlice, expandSalesNavigatorCrawlAttempt, SalesNavigatorSliceTooBroadError } from "./sales-navigator.js";
24
+ import { buildSalesNavigatorCrawlPreview, createSalesNavigatorCrawlSeed, DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS, buildSalesNavigatorPeopleSlice, deriveSalesNavigatorTitleQuerySeeds, expandSalesNavigatorCrawlAttempt, SalesNavigatorSliceTooBroadError } from "./sales-navigator.js";
25
+ import { buildSalesNavigatorHistoricalBackfillPlan, ensureSalesNavigatorPeopleCount, resolveSalesNavigatorHistoricalBackfillConfig, resolveSalesNavigatorHistoricalBackfillResumeState, resolveSalesNavigatorHistoricalBackfillOrgId, salesNavigatorHistoricalBackfillDefaults } from "./salesnav-backfill.js";
24
26
  const require = createRequire(import.meta.url);
25
27
  const { version: packageVersion } = require("../package.json");
26
28
  const program = new Command();
@@ -50,12 +52,31 @@ const LinkedInProductIngestResponseSchema = z.object({
50
52
  upserted: z.number().int().nonnegative(),
51
53
  totalInCatalog: z.number().int().nonnegative().optional()
52
54
  });
55
+ const SalesNavigatorLaunchDiagnosticsSchema = z.object({
56
+ orderedCandidateAgentIds: z.array(z.string().min(1)),
57
+ runningAgentIds: z.array(z.string().min(1)),
58
+ busyAgentIds: z.array(z.string().min(1)),
59
+ selectedAgent: z.object({
60
+ id: z.string().min(1),
61
+ name: z.string().min(1),
62
+ maxParallelism: z.number().int().nullable(),
63
+ fileMgmt: z.string().min(1).nullable(),
64
+ hasWebhook: z.boolean(),
65
+ hasStoredSessionCookie: z.boolean(),
66
+ storedIdentityCount: z.number().int().nonnegative(),
67
+ supportsDirectSessionInjection: z.boolean()
68
+ })
69
+ });
53
70
  const SalesNavigatorExportStartResponseSchema = z.object({
54
71
  status: z.literal("accepted"),
55
72
  runId: z.string().min(1),
56
73
  exportStatus: z.literal("pending"),
57
74
  agentId: z.string().min(1),
58
75
  containerId: z.string().min(1),
76
+ selectedSessionCookieSha256: z.string().min(1).nullable().optional(),
77
+ selectedSessionUserEmail: z.string().min(1).nullable().optional(),
78
+ selectedSessionUserHandle: z.string().min(1).nullable().optional(),
79
+ launchDiagnostics: SalesNavigatorLaunchDiagnosticsSchema.nullable().optional(),
59
80
  sourceQueryUrl: z.string().url(),
60
81
  slicedQueryUrl: z.string().url(),
61
82
  previousContainerId: z.string().min(1).nullable().optional()
@@ -79,6 +100,10 @@ const SalesNavigatorExportRunSchema = z.object({
79
100
  resultCsvUrl: z.string().url().nullable().optional(),
80
101
  agentId: z.string().min(1),
81
102
  containerId: z.string().min(1),
103
+ selectedSessionCookieSha256: z.string().min(1).nullable().optional(),
104
+ selectedSessionUserEmail: z.string().min(1).nullable().optional(),
105
+ selectedSessionUserHandle: z.string().min(1).nullable().optional(),
106
+ launchDiagnostics: SalesNavigatorLaunchDiagnosticsSchema.nullable().optional(),
82
107
  sourceQueryUrl: z.string().url(),
83
108
  slicedQueryUrl: z.string().url(),
84
109
  createdAt: z.string().datetime(),
@@ -99,6 +124,10 @@ const SalesNavigatorExportResponseSchema = z.object({
99
124
  resultCsvUrl: z.string().url().nullable().optional(),
100
125
  agentId: z.string().min(1),
101
126
  containerId: z.string().min(1),
127
+ selectedSessionCookieSha256: z.string().min(1).nullable().optional(),
128
+ selectedSessionUserEmail: z.string().min(1).nullable().optional(),
129
+ selectedSessionUserHandle: z.string().min(1).nullable().optional(),
130
+ launchDiagnostics: SalesNavigatorLaunchDiagnosticsSchema.nullable().optional(),
102
131
  sourceQueryUrl: z.string().url(),
103
132
  slicedQueryUrl: z.string().url()
104
133
  });
@@ -188,6 +217,12 @@ function printOutput(value) {
188
217
  const space = runtimeOutputOptions.json ? undefined : 2;
189
218
  process.stdout.write(`${JSON.stringify(value, null, space)}\n`);
190
219
  }
220
+ function writeProgress(message) {
221
+ if (runtimeOutputOptions.json || runtimeOutputOptions.quiet) {
222
+ return;
223
+ }
224
+ process.stderr.write(`${message}\n`);
225
+ }
191
226
  function applyGlobalOutputOptions(actionCommand) {
192
227
  const globalOptions = actionCommand.optsWithGlobals();
193
228
  runtimeOutputOptions.json = Boolean(globalOptions.json);
@@ -687,6 +722,449 @@ async function fetchWorkspaceLeadSearch(session, requestBody) {
687
722
  function buildLinkedInProductsOutputPath(categorySlug) {
688
723
  return `./data/linkedin-products-${categorySlug}.json`;
689
724
  }
725
+ function buildLinkedInProductCategorySalesNavigatorOutputPath(categorySlug) {
726
+ return `./data/salesnav-product-category-${categorySlug}.json`;
727
+ }
728
+ const SALES_NAVIGATOR_TERMINAL_JOB_STATUSES = new Set(["completed", "completed_with_failures"]);
729
+ function isSalesNavigatorCrawlJobTerminal(status) {
730
+ return SALES_NAVIGATOR_TERMINAL_JOB_STATUSES.has(status);
731
+ }
732
+ function buildWorkflowTraceId(prefix) {
733
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
734
+ }
735
+ function buildSalesNavigatorWorkflowLogPath(input) {
736
+ const slug = slugify(input) || "salesnav-product-category";
737
+ return `./data/${slug}-salesnav.log.jsonl`;
738
+ }
739
+ function buildSalesNavigatorCrawlLogPath(input) {
740
+ const slug = slugify(input) || "salesnav-crawl";
741
+ return `./data/${slug}-crawl.log.jsonl`;
742
+ }
743
+ function decodeSalesNavigatorQueryParam(url) {
744
+ try {
745
+ const encoded = new URL(url).searchParams.get("query");
746
+ if (!encoded) {
747
+ return null;
748
+ }
749
+ try {
750
+ return decodeURIComponent(encoded);
751
+ }
752
+ catch {
753
+ return encoded;
754
+ }
755
+ }
756
+ catch {
757
+ return null;
758
+ }
759
+ }
760
+ async function createWorkflowLogger(options) {
761
+ const traceId = options.traceId ?? buildWorkflowTraceId("salesprompter-cli");
762
+ const logPath = options.logPath;
763
+ await mkdir(path.dirname(logPath), { recursive: true });
764
+ return {
765
+ traceId,
766
+ logPath,
767
+ log: async (event, metadata = {}) => {
768
+ const entry = {
769
+ timestamp: new Date().toISOString(),
770
+ traceId,
771
+ event,
772
+ metadata
773
+ };
774
+ await appendFile(logPath, `${JSON.stringify(entry)}\n`, "utf8");
775
+ writeProgress(`[${entry.timestamp}] ${event}`);
776
+ }
777
+ };
778
+ }
779
+ function summarizeSalesNavigatorQuery(url, appliedFilters) {
780
+ return {
781
+ url,
782
+ decodedQuery: decodeSalesNavigatorQueryParam(url),
783
+ appliedFilters
784
+ };
785
+ }
786
+ function extractSalesNavigatorFilterTypes(url, appliedFilters) {
787
+ const filterTypes = new Set(appliedFilters.map((filter) => filter.type));
788
+ const decodedQuery = decodeSalesNavigatorQueryParam(url) ?? "";
789
+ for (const match of decodedQuery.matchAll(/type:([A-Z_]+)/g)) {
790
+ const value = match[1]?.trim();
791
+ if (value) {
792
+ filterTypes.add(value);
793
+ }
794
+ }
795
+ return [...filterTypes];
796
+ }
797
+ function shouldPreSplitSalesNavigatorRootSlice(slice, maxSplitDepth) {
798
+ if (slice.depth !== 0 || slice.splitTrail.length > 0) {
799
+ return false;
800
+ }
801
+ if (!nextSalesNavigatorSplitDimension(slice, maxSplitDepth)) {
802
+ return false;
803
+ }
804
+ const filterTypes = new Set(extractSalesNavigatorFilterTypes(slice.slicedQueryUrl, slice.appliedFilters));
805
+ if (!filterTypes.has("CURRENT_TITLE")) {
806
+ return false;
807
+ }
808
+ return !DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS.some((dimension) => filterTypes.has(dimension.filterType));
809
+ }
810
+ function buildTraceHeaders(traceId) {
811
+ return traceId ? { "X-Salesprompter-Trace-Id": traceId } : {};
812
+ }
813
+ function buildSalesNavigatorWorkflowCrawlSummary(crawl) {
814
+ const successful = crawl.job.status === "completed" && !crawl.truncated;
815
+ return {
816
+ jobStatus: crawl.job.status,
817
+ importedPeople: crawl.job.importedPeople,
818
+ exportedSlices: crawl.job.exportedSlices,
819
+ failedSlices: crawl.job.failedSlices,
820
+ queuedSlices: crawl.job.queuedSlices,
821
+ runningSlices: crawl.job.runningSlices,
822
+ truncated: crawl.truncated,
823
+ successful
824
+ };
825
+ }
826
+ function buildSalesNavigatorWorkflowSummary(crawls) {
827
+ return crawls.reduce((summary, crawl) => {
828
+ summary.totalImportedPeople += crawl.summary.importedPeople;
829
+ summary.totalExportedSlices += crawl.summary.exportedSlices;
830
+ summary.totalFailedSlices += crawl.summary.failedSlices;
831
+ if (crawl.summary.truncated) {
832
+ summary.truncatedTitles += 1;
833
+ }
834
+ if (crawl.summary.jobStatus === "completed") {
835
+ summary.completedTitles += 1;
836
+ }
837
+ else if (crawl.summary.jobStatus === "completed_with_failures") {
838
+ summary.completedWithFailuresTitles += 1;
839
+ }
840
+ else {
841
+ summary.runningTitles += 1;
842
+ }
843
+ if (!crawl.summary.successful) {
844
+ summary.workflowStatus = "completed_with_failures";
845
+ }
846
+ return summary;
847
+ }, {
848
+ workflowStatus: "completed",
849
+ totalImportedPeople: 0,
850
+ totalExportedSlices: 0,
851
+ totalFailedSlices: 0,
852
+ completedTitles: 0,
853
+ completedWithFailuresTitles: 0,
854
+ runningTitles: 0,
855
+ truncatedTitles: 0
856
+ });
857
+ }
858
+ function buildSalesNavigatorWorkflowFailureMessage(summary) {
859
+ return [
860
+ "Sales Navigator workflow completed with failures.",
861
+ `completedTitles=${summary.completedTitles}`,
862
+ `completedWithFailuresTitles=${summary.completedWithFailuresTitles}`,
863
+ `runningTitles=${summary.runningTitles}`,
864
+ `truncatedTitles=${summary.truncatedTitles}`,
865
+ `totalFailedSlices=${summary.totalFailedSlices}`
866
+ ].join(" ");
867
+ }
868
+ function validateSalesNavigatorSeedQuery(seed) {
869
+ const decodedQuery = decodeSalesNavigatorQueryParam(seed.queryUrl);
870
+ const haystack = decodedQuery?.toLowerCase() ?? "";
871
+ const missingFilters = seed.appliedFilters.flatMap((filter) => {
872
+ const missingValues = filter.values.filter((value) => !haystack.includes(value.text.toLowerCase()));
873
+ if (!haystack.includes(filter.type.toLowerCase()) || missingValues.length > 0) {
874
+ return `${filter.type}:${missingValues.map((value) => value.text).join(",") || "*"}`;
875
+ }
876
+ return [];
877
+ });
878
+ return {
879
+ valid: missingFilters.length === 0,
880
+ missingFilters,
881
+ decodedQuery
882
+ };
883
+ }
884
+ async function runSalesNavigatorFromProductCategoryWorkflow(options) {
885
+ const logger = await createWorkflowLogger({
886
+ logPath: options.logPath ?? buildSalesNavigatorWorkflowLogPath(options.input)
887
+ });
888
+ await logger.log("workflow.started", {
889
+ input: options.input,
890
+ maxPages: options.maxPages,
891
+ productLimit: options.productLimit ?? null,
892
+ titleLimit: options.titleLimit ?? null,
893
+ maxResultsPerSearch: options.maxResultsPerSearch,
894
+ numberOfProfiles: options.numberOfProfiles,
895
+ slicePreset: options.slicePreset,
896
+ maxSplitDepth: options.maxSplitDepth,
897
+ maxSlicesPerTitle: options.maxSlicesPerTitle,
898
+ maxRetries: options.maxRetries,
899
+ probeProfiles: options.probeProfiles,
900
+ agentBusyWaitSeconds: options.agentBusyWaitSeconds,
901
+ agentBusyMaxWaits: options.agentBusyMaxWaits,
902
+ idlePollSeconds: options.idlePollSeconds,
903
+ idleMaxPolls: options.idleMaxPolls,
904
+ parallelExports: options.parallelExports,
905
+ skipProductUpload: options.skipProductUpload,
906
+ dryRun: options.dryRun
907
+ });
908
+ try {
909
+ const scrape = await crawlLinkedInProductCategory({
910
+ input: options.input,
911
+ maxPages: options.maxPages,
912
+ limit: options.productLimit
913
+ });
914
+ await logger.log("linkedin.category.scraped", {
915
+ source: scrape.source,
916
+ totalPagesFetched: scrape.totalPagesFetched,
917
+ discoveredProducts: scrape.items.length,
918
+ productNames: scrape.items.map((item) => item.productName)
919
+ });
920
+ const titleSeeds = deriveSalesNavigatorTitleQuerySeeds({
921
+ sourceProductUrl: scrape.source.productUrl,
922
+ items: scrape.items,
923
+ titleLimit: options.titleLimit
924
+ });
925
+ if (titleSeeds.length === 0) {
926
+ throw new Error(`No intended-role job titles were found while crawling the LinkedIn product category ${scrape.source.category.name}.`);
927
+ }
928
+ const outPath = options.outPath ?? buildLinkedInProductCategorySalesNavigatorOutputPath(scrape.source.category.slug);
929
+ const previewQueries = titleSeeds.map((seed) => {
930
+ const preview = buildSalesNavigatorCrawlPreview({
931
+ sourceQueryUrl: seed.queryUrl,
932
+ maxResultsPerSearch: options.maxResultsPerSearch,
933
+ numberOfProfiles: options.numberOfProfiles,
934
+ slicePreset: options.slicePreset
935
+ });
936
+ return {
937
+ title: seed.title,
938
+ queryUrl: seed.queryUrl,
939
+ appliedFilters: seed.appliedFilters,
940
+ sourceProduct: seed.sourceProduct,
941
+ matchedProductCount: seed.matchedProductCount,
942
+ firstSplitQueries: preview.firstSplit.map((attempt) => ({
943
+ slicedQueryUrl: attempt.slicedQueryUrl,
944
+ appliedFilters: attempt.appliedFilters,
945
+ splitTrail: formatSalesNavigatorSplitTrail(attempt.splitTrail.map((entry) => ({
946
+ ...entry,
947
+ value: {
948
+ id: entry.value.id,
949
+ text: entry.value.text,
950
+ selectionType: entry.value.selectionType
951
+ }
952
+ })))
953
+ }))
954
+ };
955
+ });
956
+ await logger.log("salesnav.title-seeds.derived", {
957
+ titleCount: titleSeeds.length,
958
+ titles: titleSeeds.map((seed) => ({
959
+ title: seed.title,
960
+ sourceProduct: seed.sourceProduct,
961
+ matchedProductCount: seed.matchedProductCount,
962
+ ...summarizeSalesNavigatorQuery(seed.queryUrl, seed.appliedFilters)
963
+ }))
964
+ });
965
+ const firstSeedValidation = validateSalesNavigatorSeedQuery(titleSeeds[0]);
966
+ await logger.log("salesnav.first-query.validated", {
967
+ title: titleSeeds[0]?.title ?? null,
968
+ valid: firstSeedValidation.valid,
969
+ missingFilters: firstSeedValidation.missingFilters,
970
+ decodedQuery: firstSeedValidation.decodedQuery
971
+ });
972
+ if (!firstSeedValidation.valid) {
973
+ throw new Error(`Generated Sales Navigator seed query for "${titleSeeds[0]?.title ?? "unknown"}" is missing expected filters: ${firstSeedValidation.missingFilters.join(", ")}.`);
974
+ }
975
+ await logger.log("salesnav.first-split.preview", {
976
+ titles: previewQueries.map((query) => ({
977
+ title: query.title,
978
+ sourceProduct: query.sourceProduct,
979
+ matchedProductCount: query.matchedProductCount,
980
+ ...summarizeSalesNavigatorQuery(query.queryUrl, query.appliedFilters),
981
+ firstSplitQueries: query.firstSplitQueries.map((split) => ({
982
+ splitTrail: split.splitTrail,
983
+ ...summarizeSalesNavigatorQuery(split.slicedQueryUrl, split.appliedFilters)
984
+ }))
985
+ }))
986
+ });
987
+ if (options.dryRun) {
988
+ const payload = {
989
+ status: "ok",
990
+ dryRun: true,
991
+ mode: "linkedin-product-category-to-salesnav",
992
+ traceId: logger.traceId,
993
+ logPath: logger.logPath,
994
+ source: scrape.source,
995
+ totalPagesFetched: scrape.totalPagesFetched,
996
+ discoveredProducts: scrape.items.length,
997
+ titleCount: titleSeeds.length,
998
+ summary: {
999
+ workflowStatus: "completed",
1000
+ totalImportedPeople: 0,
1001
+ totalExportedSlices: 0,
1002
+ totalFailedSlices: 0,
1003
+ completedTitles: 0,
1004
+ completedWithFailuresTitles: 0,
1005
+ runningTitles: 0,
1006
+ truncatedTitles: 0
1007
+ },
1008
+ uploaded: null,
1009
+ queries: previewQueries
1010
+ };
1011
+ await writeJsonFile(outPath, payload);
1012
+ await logger.log("workflow.completed", {
1013
+ outPath,
1014
+ dryRun: true,
1015
+ discoveredProducts: payload.discoveredProducts,
1016
+ titleCount: payload.titleCount
1017
+ });
1018
+ return { outPath, payload };
1019
+ }
1020
+ let session = await requireAuthSession();
1021
+ let uploaded = null;
1022
+ if (!options.skipProductUpload) {
1023
+ await logger.log("linkedin.catalog.upload.started", {
1024
+ itemCount: scrape.items.length
1025
+ });
1026
+ uploaded = await uploadLinkedInProductsCatalog(session, {
1027
+ source: {
1028
+ input: scrape.source.input,
1029
+ kind: scrape.source.kind,
1030
+ query: scrape.source.query,
1031
+ companyUrl: scrape.source.companyUrl,
1032
+ productUrl: scrape.source.productUrl,
1033
+ category: scrape.source.category
1034
+ },
1035
+ items: scrape.items
1036
+ }, 100, logger.traceId);
1037
+ await logger.log("linkedin.catalog.upload.completed", uploaded);
1038
+ }
1039
+ const crawls = [];
1040
+ for (const seed of titleSeeds) {
1041
+ writeProgress(`Starting durable Sales Navigator crawl for intended role "${seed.title}".`);
1042
+ const rootSlice = createSalesNavigatorCrawlSeed({
1043
+ sourceQueryUrl: seed.queryUrl,
1044
+ maxResultsPerSearch: options.maxResultsPerSearch,
1045
+ numberOfProfiles: options.numberOfProfiles,
1046
+ slicePreset: options.slicePreset
1047
+ });
1048
+ const created = await createOrResumeSalesNavigatorCrawlJob(session, {
1049
+ sourceQueryUrl: seed.queryUrl,
1050
+ slicePreset: options.slicePreset,
1051
+ maxResultsPerSearch: options.maxResultsPerSearch,
1052
+ numberOfProfiles: options.numberOfProfiles,
1053
+ rawPayload: {
1054
+ workflow: "linkedin-product-category-to-salesnav",
1055
+ traceId: logger.traceId,
1056
+ source: scrape.source,
1057
+ titleSeed: {
1058
+ title: seed.title,
1059
+ queryUrl: seed.queryUrl,
1060
+ appliedFilters: seed.appliedFilters,
1061
+ sourceProduct: seed.sourceProduct,
1062
+ matchedProductCount: seed.matchedProductCount
1063
+ }
1064
+ },
1065
+ rootSlice: {
1066
+ slicedQueryUrl: rootSlice.slicedQueryUrl,
1067
+ appliedFilters: rootSlice.appliedFilters,
1068
+ depth: rootSlice.depth,
1069
+ splitTrail: rootSlice.splitTrail,
1070
+ rawPayload: {
1071
+ traceId: logger.traceId,
1072
+ title: seed.title,
1073
+ sourceProduct: seed.sourceProduct,
1074
+ matchedProductCount: seed.matchedProductCount,
1075
+ source: scrape.source
1076
+ }
1077
+ }
1078
+ }, logger.traceId);
1079
+ session = created.session;
1080
+ await logger.log("salesnav.crawl.job.ready", {
1081
+ title: seed.title,
1082
+ sourceProduct: seed.sourceProduct,
1083
+ matchedProductCount: seed.matchedProductCount,
1084
+ resumed: created.value.resumed,
1085
+ jobId: created.value.job.id,
1086
+ rootSlice: {
1087
+ depth: rootSlice.depth,
1088
+ splitTrail: formatSalesNavigatorSplitTrail(rootSlice.splitTrail),
1089
+ ...summarizeSalesNavigatorQuery(rootSlice.slicedQueryUrl, rootSlice.appliedFilters)
1090
+ }
1091
+ });
1092
+ const crawl = await executeSalesNavigatorCrawlJob(session, created.value.job.id, {
1093
+ maxSplitDepth: options.maxSplitDepth,
1094
+ maxSlices: options.maxSlicesPerTitle,
1095
+ maxRetries: options.maxRetries,
1096
+ probeProfiles: options.probeProfiles,
1097
+ agentBusyWaitSeconds: options.agentBusyWaitSeconds,
1098
+ agentBusyMaxWaits: options.agentBusyMaxWaits,
1099
+ idlePollSeconds: options.idlePollSeconds,
1100
+ idleMaxPolls: options.idleMaxPolls,
1101
+ parallelExports: options.parallelExports,
1102
+ traceId: logger.traceId,
1103
+ logger
1104
+ });
1105
+ session = crawl.session;
1106
+ const crawlSummary = buildSalesNavigatorWorkflowCrawlSummary(crawl);
1107
+ await logger.log("salesnav.crawl.job.finished", {
1108
+ title: seed.title,
1109
+ jobId: created.value.job.id,
1110
+ summary: crawlSummary,
1111
+ lastOutcome: crawl.lastOutcome
1112
+ });
1113
+ crawls.push({
1114
+ title: seed.title,
1115
+ sourceProduct: seed.sourceProduct,
1116
+ matchedProductCount: seed.matchedProductCount,
1117
+ queryUrl: seed.queryUrl,
1118
+ jobId: created.value.job.id,
1119
+ resumed: created.value.resumed,
1120
+ claimedSlices: crawl.claimedSlices,
1121
+ truncated: crawl.truncated,
1122
+ activeSlice: crawl.activeSlice
1123
+ ? {
1124
+ id: crawl.activeSlice.id,
1125
+ slicedQueryUrl: crawl.activeSlice.slicedQueryUrl,
1126
+ depth: crawl.activeSlice.depth,
1127
+ splitTrail: formatSalesNavigatorSplitTrail(crawl.activeSlice.splitTrail)
1128
+ }
1129
+ : null,
1130
+ lastOutcome: crawl.lastOutcome,
1131
+ job: crawl.job,
1132
+ summary: crawlSummary
1133
+ });
1134
+ }
1135
+ const summary = buildSalesNavigatorWorkflowSummary(crawls);
1136
+ const payload = {
1137
+ status: "ok",
1138
+ dryRun: false,
1139
+ mode: "linkedin-product-category-to-salesnav",
1140
+ traceId: logger.traceId,
1141
+ logPath: logger.logPath,
1142
+ source: scrape.source,
1143
+ totalPagesFetched: scrape.totalPagesFetched,
1144
+ discoveredProducts: scrape.items.length,
1145
+ titleCount: titleSeeds.length,
1146
+ summary,
1147
+ uploaded,
1148
+ crawls
1149
+ };
1150
+ await writeJsonFile(outPath, payload);
1151
+ await logger.log("workflow.completed", {
1152
+ outPath,
1153
+ dryRun: false,
1154
+ uploaded,
1155
+ crawlCount: crawls.length,
1156
+ summary
1157
+ });
1158
+ return { outPath, payload };
1159
+ }
1160
+ catch (error) {
1161
+ await logger.log("workflow.failed", {
1162
+ message: error instanceof Error ? error.message : String(error),
1163
+ stack: error instanceof Error ? error.stack ?? null : null
1164
+ });
1165
+ throw error;
1166
+ }
1167
+ }
690
1168
  function collectStringOptionValue(value, previous = []) {
691
1169
  return [...previous, value];
692
1170
  }
@@ -696,6 +1174,7 @@ class SalesNavigatorExportRequestError extends Error {
696
1174
  runId;
697
1175
  agentId;
698
1176
  containerId;
1177
+ launchDiagnostics;
699
1178
  statusCode;
700
1179
  constructor(message, options) {
701
1180
  super(message);
@@ -706,8 +1185,10 @@ class SalesNavigatorExportRequestError extends Error {
706
1185
  this.runId = options.runId;
707
1186
  this.agentId = options.agentId;
708
1187
  this.containerId = options.containerId;
1188
+ this.launchDiagnostics = options.launchDiagnostics ?? null;
709
1189
  }
710
1190
  }
1191
+ const SALES_NAVIGATOR_EXPORT_START_TIMEOUT_MS = 90_000;
711
1192
  async function withRefreshableAuthSession(session, run, contextLabel = "Salesprompter session expired during crawl. Refreshing login...") {
712
1193
  let currentSession = session;
713
1194
  let authRefreshCount = 0;
@@ -748,7 +1229,7 @@ async function fetchCliJson(session, request, schema) {
748
1229
  return schema.parse(parsed);
749
1230
  });
750
1231
  }
751
- async function uploadLinkedInProductsCatalog(session, payload, batchSize = 100) {
1232
+ async function uploadLinkedInProductsCatalog(session, payload, batchSize = 100, traceId) {
752
1233
  let imported = 0;
753
1234
  let upserted = 0;
754
1235
  for (let startIndex = 0; startIndex < payload.items.length; startIndex += batchSize) {
@@ -757,7 +1238,8 @@ async function uploadLinkedInProductsCatalog(session, payload, batchSize = 100)
757
1238
  method: "POST",
758
1239
  headers: {
759
1240
  "Content-Type": "application/json",
760
- Authorization: `Bearer ${session.accessToken}`
1241
+ Authorization: `Bearer ${session.accessToken}`,
1242
+ ...buildTraceHeaders(traceId)
761
1243
  },
762
1244
  body: JSON.stringify({
763
1245
  source: payload.source,
@@ -788,17 +1270,149 @@ function serializeSalesNavigatorFiltersForApi(filters) {
788
1270
  }))
789
1271
  }));
790
1272
  }
791
- async function runSalesNavigatorExport(session, payload) {
792
- const started = await startSalesNavigatorExport(session, payload);
793
- const completed = await waitForSalesNavigatorExportRunCompletion(started.session, started.value.runId);
794
- return mapCompletedSalesNavigatorExportRun(completed.value.run);
1273
+ function buildSalesNavigatorSliceRawPayload(slice, extra = {}) {
1274
+ return {
1275
+ ...extra,
1276
+ sourceQueryUrl: slice.sourceQueryUrl,
1277
+ slicedQueryUrl: slice.slicedQueryUrl,
1278
+ appliedFilters: slice.appliedFilters,
1279
+ depth: slice.depth,
1280
+ splitTrail: slice.splitTrail,
1281
+ slicePreset: slice.slicePreset,
1282
+ maxResultsPerSearch: slice.maxResultsPerSearch,
1283
+ numberOfProfiles: slice.numberOfProfiles,
1284
+ retryCount: slice.retryCount ?? null,
1285
+ cookieRetryCount: slice.cookieRetryCount ?? null,
1286
+ resultRetryCount: slice.resultRetryCount ?? null
1287
+ };
795
1288
  }
796
- async function startSalesNavigatorExport(session, payload) {
1289
+ function buildSalesNavigatorCrawlReportRawPayload(slice, traceId, extra = {}) {
1290
+ return buildSalesNavigatorSliceRawPayload({
1291
+ sourceQueryUrl: slice.sourceQueryUrl,
1292
+ slicedQueryUrl: slice.slicedQueryUrl,
1293
+ appliedFilters: slice.appliedFilters,
1294
+ depth: slice.depth,
1295
+ splitTrail: slice.splitTrail,
1296
+ slicePreset: slice.slicePreset,
1297
+ maxResultsPerSearch: slice.maxResultsPerSearch,
1298
+ numberOfProfiles: slice.numberOfProfiles,
1299
+ retryCount: slice.retryCount,
1300
+ cookieRetryCount: slice.cookieRetryCount,
1301
+ resultRetryCount: slice.resultRetryCount
1302
+ }, {
1303
+ traceId: traceId ?? null,
1304
+ sliceId: slice.id,
1305
+ jobId: slice.jobId,
1306
+ ...extra
1307
+ });
1308
+ }
1309
+ function describeSalesNavigatorLaunchDiagnostics(diagnostics) {
1310
+ if (!diagnostics) {
1311
+ return null;
1312
+ }
1313
+ const parts = [
1314
+ `agent ${diagnostics.selectedAgent.name} (${diagnostics.selectedAgent.id})`,
1315
+ diagnostics.runningAgentIds.length > 0
1316
+ ? `running: ${diagnostics.runningAgentIds.join(", ")}`
1317
+ : null,
1318
+ diagnostics.busyAgentIds.length > 0
1319
+ ? `busy fallback: ${diagnostics.busyAgentIds.join(", ")}`
1320
+ : null,
1321
+ diagnostics.selectedAgent.maxParallelism !== null
1322
+ ? `parallelism ${diagnostics.selectedAgent.maxParallelism}`
1323
+ : null,
1324
+ diagnostics.selectedAgent.fileMgmt
1325
+ ? `file mgmt ${diagnostics.selectedAgent.fileMgmt}`
1326
+ : null,
1327
+ diagnostics.selectedAgent.hasWebhook ? "webhook on" : "webhook off",
1328
+ diagnostics.selectedAgent.hasStoredSessionCookie
1329
+ ? "stored phantom cookie present"
1330
+ : "stored phantom cookie cleared at launch",
1331
+ ].filter((value) => Boolean(value));
1332
+ return parts.join("; ");
1333
+ }
1334
+ function writeSalesNavigatorLaunchDiagnosticsProgress(diagnostics, selectedSessionUserEmail) {
1335
+ if (runtimeOutputOptions.json || runtimeOutputOptions.quiet || !diagnostics) {
1336
+ return;
1337
+ }
1338
+ const details = describeSalesNavigatorLaunchDiagnostics(diagnostics);
1339
+ const operator = selectedSessionUserEmail ? ` using ${selectedSessionUserEmail}` : "";
1340
+ process.stderr.write(`Phantombuster launch selected ${diagnostics.selectedAgent.id}${operator}.${details ? ` ${details}` : ""}\n`);
1341
+ }
1342
+ async function runSalesNavigatorExport(session, payload, traceId, logOptions = {}) {
1343
+ const baseMetadata = {
1344
+ sourceQueryUrl: payload.sourceQueryUrl,
1345
+ slicedQueryUrl: payload.slicedQueryUrl,
1346
+ slicePreset: payload.slicePreset,
1347
+ maxResultsPerSearch: payload.maxResultsPerSearch,
1348
+ numberOfProfiles: payload.numberOfProfiles,
1349
+ filterTypes: payload.appliedFilters.map((filter) => filter.type),
1350
+ ...logOptions.metadata
1351
+ };
1352
+ await logOptions.logger?.log("salesnav.export.started", baseMetadata);
1353
+ try {
1354
+ const started = await startSalesNavigatorExport(session, payload, traceId);
1355
+ await logOptions.logger?.log("salesnav.export.accepted", {
1356
+ ...baseMetadata,
1357
+ runId: started.value.runId,
1358
+ agentId: started.value.agentId,
1359
+ containerId: started.value.containerId,
1360
+ previousContainerId: started.value.previousContainerId ?? null,
1361
+ selectedSessionCookieSha256: started.value.selectedSessionCookieSha256 ?? null,
1362
+ selectedSessionUserEmail: started.value.selectedSessionUserEmail ?? null,
1363
+ selectedSessionUserHandle: started.value.selectedSessionUserHandle ?? null,
1364
+ launchDiagnostics: started.value.launchDiagnostics ?? null
1365
+ });
1366
+ writeSalesNavigatorLaunchDiagnosticsProgress(started.value.launchDiagnostics ?? null, started.value.selectedSessionUserEmail ?? null);
1367
+ const completed = await waitForSalesNavigatorExportRunCompletion(started.session, started.value.runId, {}, traceId, {
1368
+ logger: logOptions.logger,
1369
+ metadata: baseMetadata
1370
+ });
1371
+ await logOptions.logger?.log("salesnav.export.completed", {
1372
+ ...baseMetadata,
1373
+ runId: completed.value.run.id,
1374
+ status: completed.value.run.status,
1375
+ resultClassification: completed.value.run.resultClassification,
1376
+ totalResults: completed.value.run.totalResults ?? null,
1377
+ imported: completed.value.run.imported,
1378
+ upserted: completed.value.run.upserted,
1379
+ updatedAt: completed.value.run.updatedAt,
1380
+ finishedAt: completed.value.run.finishedAt ?? null
1381
+ });
1382
+ const mapped = mapCompletedSalesNavigatorExportRun(completed.value.run);
1383
+ return SalesNavigatorExportResponseSchema.parse({
1384
+ ...mapped,
1385
+ launchDiagnostics: mapped.launchDiagnostics ?? started.value.launchDiagnostics ?? null,
1386
+ });
1387
+ }
1388
+ catch (error) {
1389
+ await logOptions.logger?.log("salesnav.export.failed", {
1390
+ ...baseMetadata,
1391
+ name: error instanceof Error ? error.name : "Error",
1392
+ message: error instanceof Error ? error.message : String(error),
1393
+ ...(error instanceof SalesNavigatorExportRequestError
1394
+ ? {
1395
+ runId: error.runId ?? null,
1396
+ agentId: error.agentId ?? null,
1397
+ containerId: error.containerId ?? null,
1398
+ errorCode: error.errorCode ?? null,
1399
+ totalResults: error.totalResults ?? null,
1400
+ launchDiagnostics: error.launchDiagnostics ?? null,
1401
+ statusCode: error.statusCode
1402
+ }
1403
+ : {})
1404
+ });
1405
+ throw error;
1406
+ }
1407
+ }
1408
+ async function startSalesNavigatorExport(session, payload, traceId) {
797
1409
  return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/export`, {
798
1410
  method: "POST",
1411
+ signal: AbortSignal.timeout(SALES_NAVIGATOR_EXPORT_START_TIMEOUT_MS),
799
1412
  headers: {
800
1413
  "Content-Type": "application/json",
801
- Authorization: `Bearer ${currentSession.accessToken}`
1414
+ Authorization: `Bearer ${currentSession.accessToken}`,
1415
+ ...buildTraceHeaders(traceId)
802
1416
  },
803
1417
  body: JSON.stringify({
804
1418
  ...payload,
@@ -806,11 +1420,12 @@ async function startSalesNavigatorExport(session, payload) {
806
1420
  })
807
1421
  }), SalesNavigatorExportStartResponseSchema);
808
1422
  }
809
- async function getSalesNavigatorExportRunStatus(session, runId) {
1423
+ async function getSalesNavigatorExportRunStatus(session, runId, traceId) {
810
1424
  return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/export-runs/${runId}?refresh=1`, {
811
1425
  method: "GET",
812
1426
  headers: {
813
- Authorization: `Bearer ${currentSession.accessToken}`
1427
+ Authorization: `Bearer ${currentSession.accessToken}`,
1428
+ ...buildTraceHeaders(traceId)
814
1429
  }
815
1430
  }), SalesNavigatorExportRunStatusResponseSchema);
816
1431
  }
@@ -831,7 +1446,8 @@ function mapCompletedSalesNavigatorExportRun(run) {
831
1446
  totalResults: run.totalResults ?? null,
832
1447
  runId: run.id,
833
1448
  agentId: run.agentId,
834
- containerId: run.containerId
1449
+ containerId: run.containerId,
1450
+ launchDiagnostics: run.launchDiagnostics ?? null
835
1451
  });
836
1452
  }
837
1453
  return SalesNavigatorExportResponseSchema.parse({
@@ -844,18 +1460,35 @@ function mapCompletedSalesNavigatorExportRun(run) {
844
1460
  resultCsvUrl: run.resultCsvUrl ?? null,
845
1461
  agentId: run.agentId,
846
1462
  containerId: run.containerId,
1463
+ selectedSessionCookieSha256: run.selectedSessionCookieSha256 ?? null,
1464
+ selectedSessionUserEmail: run.selectedSessionUserEmail ?? null,
1465
+ selectedSessionUserHandle: run.selectedSessionUserHandle ?? null,
1466
+ launchDiagnostics: run.launchDiagnostics ?? null,
847
1467
  sourceQueryUrl: run.sourceQueryUrl,
848
1468
  slicedQueryUrl: run.slicedQueryUrl
849
1469
  });
850
1470
  }
851
- async function waitForSalesNavigatorExportRunCompletion(session, runId, options = {}) {
1471
+ async function waitForSalesNavigatorExportRunCompletion(session, runId, options = {}, traceId, logOptions = {}) {
852
1472
  const timeoutSeconds = options.timeoutSeconds ?? 960;
853
1473
  const pollIntervalMs = options.pollIntervalMs ?? 5000;
854
1474
  const deadline = Date.now() + timeoutSeconds * 1000;
855
1475
  let currentSession = session;
1476
+ let pollCount = 0;
856
1477
  while (Date.now() < deadline) {
857
- const status = await getSalesNavigatorExportRunStatus(currentSession, runId);
1478
+ const status = await getSalesNavigatorExportRunStatus(currentSession, runId, traceId);
858
1479
  currentSession = status.session;
1480
+ pollCount += 1;
1481
+ await logOptions.logger?.log("salesnav.export.polled", {
1482
+ runId,
1483
+ pollCount,
1484
+ status: status.value.run.status,
1485
+ resultClassification: status.value.run.resultClassification,
1486
+ totalResults: status.value.run.totalResults ?? null,
1487
+ imported: status.value.run.imported,
1488
+ upserted: status.value.run.upserted,
1489
+ updatedAt: status.value.run.updatedAt,
1490
+ ...logOptions.metadata
1491
+ });
859
1492
  if (status.value.run.status !== "pending") {
860
1493
  return status;
861
1494
  }
@@ -871,12 +1504,16 @@ function isSalesNavigatorAgentBusyError(error) {
871
1504
  return /parallel executions limit/i.test(message);
872
1505
  }
873
1506
  function isSalesNavigatorSessionError(error) {
874
- if (error instanceof SalesNavigatorExportRequestError &&
875
- ["phantombuster_cant_connect_profile", "salesnav_upsell_detected", "linkedin_session_invalid"].includes(error.errorCode ?? "")) {
876
- return true;
1507
+ if (error instanceof SalesNavigatorExportRequestError) {
1508
+ if (error.errorCode === "invalid_session") {
1509
+ return true;
1510
+ }
1511
+ if (["phantombuster_cant_connect_profile", "salesnav_upsell_detected", "linkedin_session_invalid"].includes(error.errorCode ?? "")) {
1512
+ return true;
1513
+ }
877
1514
  }
878
1515
  const message = error instanceof Error ? error.message : String(error);
879
- return /can't connect profile|sales navigator account|upsell|linkedin session invalid/i.test(message);
1516
+ 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);
880
1517
  }
881
1518
  function isSalesNavigatorResultArtifactError(error) {
882
1519
  if (error instanceof SalesNavigatorExportRequestError && error.errorCode === "phantombuster_result_invalid") {
@@ -899,13 +1536,16 @@ function isRefreshableAuthError(error) {
899
1536
  const message = error instanceof Error ? error.message : String(error);
900
1537
  return /token expired|session expired|not logged in|missing bearer token/i.test(message);
901
1538
  }
902
- async function runSalesNavigatorExportWithAgentWait(session, payload, options) {
1539
+ async function runSalesNavigatorExportWithAgentWait(session, payload, options, traceId) {
903
1540
  let busyWaitCount = 0;
904
1541
  let currentSession = session;
905
1542
  let authRefreshCount = 0;
906
1543
  while (true) {
907
1544
  try {
908
- return await runSalesNavigatorExport(currentSession, payload);
1545
+ return await runSalesNavigatorExport(currentSession, payload, traceId, {
1546
+ logger: options.logger,
1547
+ metadata: options.logMetadata
1548
+ });
909
1549
  }
910
1550
  catch (error) {
911
1551
  if (isRefreshableAuthError(error)) {
@@ -916,6 +1556,12 @@ async function runSalesNavigatorExportWithAgentWait(session, payload, options) {
916
1556
  if (!runtimeOutputOptions.quiet) {
917
1557
  process.stderr.write("Salesprompter session expired during crawl. Refreshing login...\n");
918
1558
  }
1559
+ await options.logger?.log("salesnav.export.auth.refresh", {
1560
+ authRefreshCount,
1561
+ waitSeconds: options.waitSeconds,
1562
+ maxWaits: options.maxWaits,
1563
+ ...options.logMetadata
1564
+ });
919
1565
  await ensureInteractiveAuthSession(currentSession.apiBaseUrl);
920
1566
  currentSession = await requireAuthSession();
921
1567
  continue;
@@ -928,6 +1574,12 @@ async function runSalesNavigatorExportWithAgentWait(session, payload, options) {
928
1574
  if (!runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
929
1575
  process.stderr.write(`Sales Navigator export agent is busy. Waiting ${options.waitSeconds}s before retrying...\n`);
930
1576
  }
1577
+ await options.logger?.log("salesnav.export.agent.busy", {
1578
+ busyWaitCount,
1579
+ waitSeconds: options.waitSeconds,
1580
+ maxWaits: options.maxWaits,
1581
+ ...options.logMetadata
1582
+ });
931
1583
  await delay(options.waitSeconds * 1000);
932
1584
  continue;
933
1585
  }
@@ -940,6 +1592,14 @@ async function runSalesNavigatorCrawlAttempt(session, attempt, options, context)
940
1592
  options.probeProfiles < attempt.numberOfProfiles &&
941
1593
  attempt.depth < options.maxSplitDepth;
942
1594
  const probeProfiles = shouldProbe ? Math.max(1, options.probeProfiles) : attempt.numberOfProfiles;
1595
+ const logMetadata = {
1596
+ crawlJobId: context?.crawlJobId ?? null,
1597
+ crawlSliceId: context?.crawlSliceId ?? null,
1598
+ sliceDepth: attempt.depth,
1599
+ splitTrail: formatSalesNavigatorSplitTrail(attempt.splitTrail),
1600
+ sourceQueryUrl: attempt.sourceQueryUrl,
1601
+ slicedQueryUrl: attempt.slicedQueryUrl
1602
+ };
943
1603
  const probeResult = await runSalesNavigatorExportWithAgentWait(session, {
944
1604
  sourceQueryUrl: attempt.sourceQueryUrl,
945
1605
  slicedQueryUrl: attempt.slicedQueryUrl,
@@ -948,11 +1608,23 @@ async function runSalesNavigatorCrawlAttempt(session, attempt, options, context)
948
1608
  numberOfProfiles: probeProfiles,
949
1609
  slicePreset: attempt.slicePreset,
950
1610
  crawlJobId: context?.crawlJobId,
951
- crawlSliceId: context?.crawlSliceId
1611
+ crawlSliceId: context?.crawlSliceId,
1612
+ rawPayload: buildSalesNavigatorSliceRawPayload(attempt, {
1613
+ traceId: context?.traceId ?? null,
1614
+ phase: shouldProbe ? "probe" : "full_export",
1615
+ requestedProfiles: probeProfiles,
1616
+ crawlJobId: context?.crawlJobId ?? null,
1617
+ crawlSliceId: context?.crawlSliceId ?? null
1618
+ })
952
1619
  }, {
953
1620
  waitSeconds: options.agentBusyWaitSeconds,
954
- maxWaits: options.agentBusyMaxWaits
955
- });
1621
+ maxWaits: options.agentBusyMaxWaits,
1622
+ logger: options.logger,
1623
+ logMetadata: {
1624
+ ...logMetadata,
1625
+ phase: shouldProbe ? "probe" : "full_export"
1626
+ }
1627
+ }, context?.traceId);
956
1628
  if (!shouldProbe) {
957
1629
  return probeResult;
958
1630
  }
@@ -968,11 +1640,27 @@ async function runSalesNavigatorCrawlAttempt(session, attempt, options, context)
968
1640
  numberOfProfiles: attempt.numberOfProfiles,
969
1641
  slicePreset: attempt.slicePreset,
970
1642
  crawlJobId: context?.crawlJobId,
971
- crawlSliceId: context?.crawlSliceId
1643
+ crawlSliceId: context?.crawlSliceId,
1644
+ rawPayload: buildSalesNavigatorSliceRawPayload(attempt, {
1645
+ traceId: context?.traceId ?? null,
1646
+ phase: "full_export_after_probe",
1647
+ requestedProfiles: attempt.numberOfProfiles,
1648
+ crawlJobId: context?.crawlJobId ?? null,
1649
+ crawlSliceId: context?.crawlSliceId ?? null,
1650
+ probeProfiles,
1651
+ probeTotalResults: totalResults
1652
+ })
972
1653
  }, {
973
1654
  waitSeconds: options.agentBusyWaitSeconds,
974
- maxWaits: options.agentBusyMaxWaits
975
- });
1655
+ maxWaits: options.agentBusyMaxWaits,
1656
+ logger: options.logger,
1657
+ logMetadata: {
1658
+ ...logMetadata,
1659
+ phase: "full_export_after_probe",
1660
+ probeProfiles,
1661
+ probeTotalResults: totalResults
1662
+ }
1663
+ }, context?.traceId);
976
1664
  }
977
1665
  function buildSalesNavigatorCrawlAttemptFromClaimedSlice(slice) {
978
1666
  return {
@@ -987,12 +1675,13 @@ function buildSalesNavigatorCrawlAttemptFromClaimedSlice(slice) {
987
1675
  splitTrail: slice.splitTrail
988
1676
  };
989
1677
  }
990
- async function createOrResumeSalesNavigatorCrawlJob(session, payload) {
1678
+ async function createOrResumeSalesNavigatorCrawlJob(session, payload, traceId) {
991
1679
  return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/crawls`, {
992
1680
  method: "POST",
993
1681
  headers: {
994
1682
  "Content-Type": "application/json",
995
- Authorization: `Bearer ${currentSession.accessToken}`
1683
+ Authorization: `Bearer ${currentSession.accessToken}`,
1684
+ ...buildTraceHeaders(traceId)
996
1685
  },
997
1686
  body: JSON.stringify({
998
1687
  ...payload,
@@ -1003,28 +1692,31 @@ async function createOrResumeSalesNavigatorCrawlJob(session, payload) {
1003
1692
  })
1004
1693
  }), SalesNavigatorCrawlCreateResponseSchema);
1005
1694
  }
1006
- async function getSalesNavigatorCrawlStatus(session, jobId) {
1695
+ async function getSalesNavigatorCrawlStatus(session, jobId, traceId) {
1007
1696
  return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/crawls/${jobId}`, {
1008
1697
  method: "GET",
1009
1698
  headers: {
1010
- Authorization: `Bearer ${currentSession.accessToken}`
1699
+ Authorization: `Bearer ${currentSession.accessToken}`,
1700
+ ...buildTraceHeaders(traceId)
1011
1701
  }
1012
1702
  }), SalesNavigatorCrawlStatusResponseSchema);
1013
1703
  }
1014
- async function claimNextSalesNavigatorCrawlSlice(session, jobId) {
1704
+ async function claimNextSalesNavigatorCrawlSlice(session, jobId, traceId) {
1015
1705
  return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/crawls/${jobId}/claim-next`, {
1016
1706
  method: "POST",
1017
1707
  headers: {
1018
- Authorization: `Bearer ${currentSession.accessToken}`
1708
+ Authorization: `Bearer ${currentSession.accessToken}`,
1709
+ ...buildTraceHeaders(traceId)
1019
1710
  }
1020
1711
  }), SalesNavigatorCrawlClaimResponseSchema);
1021
1712
  }
1022
- async function reportSalesNavigatorCrawlSlice(session, jobId, payload) {
1713
+ async function reportSalesNavigatorCrawlSlice(session, jobId, payload, traceId) {
1023
1714
  return await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/salesnav/crawls/${jobId}/report`, {
1024
1715
  method: "POST",
1025
1716
  headers: {
1026
1717
  "Content-Type": "application/json",
1027
- Authorization: `Bearer ${currentSession.accessToken}`
1718
+ Authorization: `Bearer ${currentSession.accessToken}`,
1719
+ ...buildTraceHeaders(traceId)
1028
1720
  },
1029
1721
  body: JSON.stringify({
1030
1722
  ...payload,
@@ -1049,7 +1741,13 @@ function buildSalesNavigatorSplitChildren(slice, dimension) {
1049
1741
  slicedQueryUrl: child.slicedQueryUrl,
1050
1742
  appliedFilters: child.appliedFilters,
1051
1743
  depth: child.depth,
1052
- splitTrail: child.splitTrail
1744
+ splitTrail: child.splitTrail,
1745
+ rawPayload: buildSalesNavigatorSliceRawPayload(child, {
1746
+ parentSliceId: slice.id,
1747
+ parentSlicedQueryUrl: slice.slicedQueryUrl,
1748
+ splitDimensionKey: child.splitTrail.at(-1)?.key ?? null,
1749
+ splitDimensionFilterType: child.splitTrail.at(-1)?.filterType ?? null
1750
+ })
1053
1751
  }));
1054
1752
  }
1055
1753
  function buildSalesNavigatorSliceFailureReport(slice, error, options) {
@@ -1114,73 +1812,301 @@ function buildSalesNavigatorSliceFailureReport(slice, error, options) {
1114
1812
  function formatSalesNavigatorSplitTrail(splitTrail) {
1115
1813
  return splitTrail.map((entry) => `${entry.key}:${entry.value.text}`);
1116
1814
  }
1117
- async function executeSalesNavigatorCrawlJob(session, jobId, options) {
1815
+ async function processSalesNavigatorClaimedCrawlSlice(session, jobId, slice, options) {
1118
1816
  let currentSession = session;
1119
- let claimedSlices = 0;
1120
- let activeSlice = null;
1121
- let job = null;
1122
- let lastOutcome = null;
1123
- while (claimedSlices < options.maxSlices) {
1124
- const claimed = await claimNextSalesNavigatorCrawlSlice(currentSession, jobId);
1125
- currentSession = claimed.session;
1126
- job = claimed.value.job;
1127
- if (!claimed.value.slice) {
1128
- break;
1129
- }
1130
- const slice = claimed.value.slice;
1131
- activeSlice = slice;
1132
- claimedSlices += 1;
1133
- if (!runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
1134
- process.stderr.write(`Processing Sales Navigator slice ${claimedSlices}/${options.maxSlices}: ${slice.slicedQueryUrl}\n`);
1135
- }
1136
- try {
1137
- const result = await runSalesNavigatorCrawlAttempt(currentSession, buildSalesNavigatorCrawlAttemptFromClaimedSlice(slice), {
1138
- maxSplitDepth: options.maxSplitDepth,
1139
- probeProfiles: options.probeProfiles,
1140
- agentBusyWaitSeconds: options.agentBusyWaitSeconds,
1141
- agentBusyMaxWaits: options.agentBusyMaxWaits
1142
- }, {
1143
- crawlJobId: jobId,
1144
- crawlSliceId: slice.id
1145
- });
1817
+ await options.logger?.log("salesnav.crawl.slice.claimed", {
1818
+ jobId,
1819
+ sliceId: slice.id,
1820
+ isNewSlice: true,
1821
+ claimedSlices: options.claimedSlices,
1822
+ depth: slice.depth,
1823
+ retryCount: slice.retryCount,
1824
+ cookieRetryCount: slice.cookieRetryCount,
1825
+ resultRetryCount: slice.resultRetryCount,
1826
+ splitTrail: formatSalesNavigatorSplitTrail(slice.splitTrail),
1827
+ ...summarizeSalesNavigatorQuery(slice.slicedQueryUrl, slice.appliedFilters)
1828
+ });
1829
+ if (!runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
1830
+ process.stderr.write(`Processing Sales Navigator slice ${options.claimedSlices}: ${slice.slicedQueryUrl}\n`);
1831
+ }
1832
+ if (shouldPreSplitSalesNavigatorRootSlice(slice, options.maxSplitDepth)) {
1833
+ const nextDimension = nextSalesNavigatorSplitDimension(slice, options.maxSplitDepth);
1834
+ if (nextDimension) {
1835
+ const children = buildSalesNavigatorSplitChildren(slice, nextDimension);
1146
1836
  const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, {
1147
1837
  sliceId: slice.id,
1148
- outcome: "exported",
1149
- totalResults: result.totalResults ?? null,
1150
- exportRunId: result.runId,
1151
- importedPeople: result.imported,
1152
- upsertedPeople: result.upserted
1153
- });
1838
+ outcome: "split",
1839
+ error: `Pre-splitting broad Sales Navigator title query by ${nextDimension.key} before the first export attempt.`,
1840
+ errorCode: "presplit_root_title_query",
1841
+ children,
1842
+ rawPayload: buildSalesNavigatorCrawlReportRawPayload(slice, options.traceId, {
1843
+ phase: "presplit",
1844
+ reason: "broad_root_title_query",
1845
+ nextDimensionKey: nextDimension.key,
1846
+ nextDimensionFilterType: nextDimension.filterType,
1847
+ childCount: children.length
1848
+ })
1849
+ }, options.traceId);
1154
1850
  currentSession = reported.session;
1155
- job = reported.value.job;
1156
- lastOutcome = {
1851
+ await options.logger?.log("salesnav.crawl.slice.presplit", {
1852
+ jobId,
1853
+ sliceId: slice.id,
1854
+ nextDimension: nextDimension.key,
1855
+ childCount: children.length,
1856
+ splitTrail: formatSalesNavigatorSplitTrail(slice.splitTrail),
1857
+ ...summarizeSalesNavigatorQuery(slice.slicedQueryUrl, slice.appliedFilters),
1858
+ childQueries: children.map((child) => ({
1859
+ splitTrail: formatSalesNavigatorSplitTrail(child.splitTrail),
1860
+ ...summarizeSalesNavigatorQuery(child.slicedQueryUrl, child.appliedFilters)
1861
+ }))
1862
+ });
1863
+ return {
1864
+ session: currentSession,
1865
+ job: reported.value.job,
1866
+ activeSlice: slice,
1867
+ lastOutcome: {
1868
+ outcome: "split",
1869
+ error: `Pre-split by ${nextDimension.key}`,
1870
+ errorCode: "presplit_root_title_query",
1871
+ totalResults: null
1872
+ }
1873
+ };
1874
+ }
1875
+ }
1876
+ try {
1877
+ const result = await runSalesNavigatorCrawlAttempt(currentSession, buildSalesNavigatorCrawlAttemptFromClaimedSlice(slice), {
1878
+ maxSplitDepth: options.maxSplitDepth,
1879
+ probeProfiles: options.probeProfiles,
1880
+ agentBusyWaitSeconds: options.agentBusyWaitSeconds,
1881
+ agentBusyMaxWaits: options.agentBusyMaxWaits,
1882
+ logger: options.logger
1883
+ }, {
1884
+ crawlJobId: jobId,
1885
+ crawlSliceId: slice.id,
1886
+ traceId: options.traceId
1887
+ });
1888
+ const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, {
1889
+ sliceId: slice.id,
1890
+ outcome: "exported",
1891
+ totalResults: result.totalResults ?? null,
1892
+ exportRunId: result.runId,
1893
+ importedPeople: result.imported,
1894
+ upsertedPeople: result.upserted,
1895
+ rawPayload: buildSalesNavigatorCrawlReportRawPayload(slice, options.traceId, {
1896
+ phase: "exported",
1897
+ export: {
1898
+ runId: result.runId,
1899
+ totalResults: result.totalResults ?? null,
1900
+ imported: result.imported,
1901
+ upserted: result.upserted,
1902
+ resultJsonUrl: result.resultJsonUrl ?? null,
1903
+ resultCsvUrl: result.resultCsvUrl ?? null,
1904
+ selectedSessionCookieSha256: result.selectedSessionCookieSha256 ?? null,
1905
+ selectedSessionUserEmail: result.selectedSessionUserEmail ?? null,
1906
+ selectedSessionUserHandle: result.selectedSessionUserHandle ?? null,
1907
+ launchDiagnostics: result.launchDiagnostics ?? null
1908
+ }
1909
+ })
1910
+ }, options.traceId);
1911
+ currentSession = reported.session;
1912
+ await options.logger?.log("salesnav.crawl.slice.exported", {
1913
+ jobId,
1914
+ sliceId: slice.id,
1915
+ exportRunId: result.runId,
1916
+ totalResults: result.totalResults ?? null,
1917
+ imported: result.imported,
1918
+ upserted: result.upserted,
1919
+ selectedAgentId: result.launchDiagnostics?.selectedAgent.id ?? result.agentId,
1920
+ selectedSessionUserEmail: result.selectedSessionUserEmail ?? null
1921
+ });
1922
+ return {
1923
+ session: currentSession,
1924
+ job: reported.value.job,
1925
+ activeSlice: slice,
1926
+ lastOutcome: {
1157
1927
  outcome: "exported",
1158
1928
  runId: result.runId,
1159
1929
  totalResults: result.totalResults ?? null
1160
- };
1161
- }
1162
- catch (error) {
1163
- const payload = buildSalesNavigatorSliceFailureReport(slice, error, {
1164
- maxSplitDepth: options.maxSplitDepth,
1165
- maxRetries: options.maxRetries
1166
- });
1167
- const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, payload);
1168
- currentSession = reported.session;
1169
- job = reported.value.job;
1170
- lastOutcome = {
1930
+ }
1931
+ };
1932
+ }
1933
+ catch (error) {
1934
+ const payload = buildSalesNavigatorSliceFailureReport(slice, error, {
1935
+ maxSplitDepth: options.maxSplitDepth,
1936
+ maxRetries: options.maxRetries
1937
+ });
1938
+ payload.rawPayload = buildSalesNavigatorCrawlReportRawPayload(slice, options.traceId, {
1939
+ phase: payload.outcome,
1940
+ error: error instanceof Error
1941
+ ? {
1942
+ name: error.name,
1943
+ message: error.message,
1944
+ ...(error instanceof SalesNavigatorExportRequestError
1945
+ ? {
1946
+ launchDiagnostics: error.launchDiagnostics ?? null,
1947
+ agentId: error.agentId ?? null,
1948
+ containerId: error.containerId ?? null
1949
+ }
1950
+ : {})
1951
+ }
1952
+ : {
1953
+ name: "Error",
1954
+ message: String(error)
1955
+ }
1956
+ });
1957
+ const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, payload, options.traceId);
1958
+ currentSession = reported.session;
1959
+ await options.logger?.log("salesnav.crawl.slice.reported", {
1960
+ jobId,
1961
+ sliceId: slice.id,
1962
+ outcome: payload.outcome,
1963
+ error: payload.error ?? null,
1964
+ errorCode: payload.errorCode ?? null,
1965
+ totalResults: payload.totalResults ?? null,
1966
+ exportRunId: payload.exportRunId ?? null,
1967
+ childCount: payload.children?.length ?? 0
1968
+ });
1969
+ return {
1970
+ session: currentSession,
1971
+ job: reported.value.job,
1972
+ activeSlice: slice,
1973
+ lastOutcome: {
1171
1974
  outcome: payload.outcome,
1172
1975
  runId: payload.exportRunId,
1173
1976
  error: payload.error,
1174
1977
  errorCode: payload.errorCode,
1175
1978
  totalResults: payload.totalResults
1176
- };
1979
+ }
1980
+ };
1981
+ }
1982
+ }
1983
+ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
1984
+ let currentSession = session;
1985
+ let claimedSlices = 0;
1986
+ const seenSliceIds = new Set();
1987
+ let activeSlice = null;
1988
+ let job = null;
1989
+ let idlePollCount = 0;
1990
+ let lastOutcome = null;
1991
+ const parallelExports = Math.max(1, options.parallelExports);
1992
+ const inFlight = new Map();
1993
+ let nextSlot = 0;
1994
+ let noMoreClaimableWork = false;
1995
+ while (true) {
1996
+ while (!noMoreClaimableWork && inFlight.size < parallelExports) {
1997
+ if (claimedSlices >= options.maxSlices) {
1998
+ break;
1999
+ }
2000
+ const claimed = await claimNextSalesNavigatorCrawlSlice(currentSession, jobId, options.traceId);
2001
+ currentSession = claimed.session;
2002
+ job = claimed.value.job;
2003
+ if (!claimed.value.slice) {
2004
+ const shouldWaitForRemoteWork = !isSalesNavigatorCrawlJobTerminal(job.status) &&
2005
+ options.idleMaxPolls > 0 &&
2006
+ job.runningSlices > 0;
2007
+ if (shouldWaitForRemoteWork && inFlight.size === 0) {
2008
+ if (idlePollCount >= options.idleMaxPolls) {
2009
+ lastOutcome = {
2010
+ outcome: "terminal_failed",
2011
+ error: `Sales Navigator crawl job ${jobId} stayed non-terminal without a claimable slice after ${options.idleMaxPolls} polls.`,
2012
+ errorCode: "crawl_idle_timeout"
2013
+ };
2014
+ await options.logger?.log("salesnav.crawl.job.stalled", {
2015
+ jobId,
2016
+ status: job.status,
2017
+ queuedSlices: job.queuedSlices,
2018
+ runningSlices: job.runningSlices,
2019
+ idlePollCount,
2020
+ idleMaxPolls: options.idleMaxPolls
2021
+ });
2022
+ noMoreClaimableWork = true;
2023
+ break;
2024
+ }
2025
+ idlePollCount += 1;
2026
+ await options.logger?.log("salesnav.crawl.job.waiting", {
2027
+ jobId,
2028
+ status: job.status,
2029
+ queuedSlices: job.queuedSlices,
2030
+ runningSlices: job.runningSlices,
2031
+ idlePollCount,
2032
+ idlePollSeconds: options.idlePollSeconds
2033
+ });
2034
+ if (!runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
2035
+ process.stderr.write(`Sales Navigator crawl job ${jobId} has no claimable slice yet. Waiting ${options.idlePollSeconds}s for remote work to settle...\n`);
2036
+ }
2037
+ await delay(options.idlePollSeconds * 1000);
2038
+ const status = await getSalesNavigatorCrawlStatus(currentSession, jobId, options.traceId);
2039
+ currentSession = status.session;
2040
+ job = status.value.job;
2041
+ await options.logger?.log("salesnav.crawl.job.status.polled", {
2042
+ jobId,
2043
+ status: job.status,
2044
+ queuedSlices: job.queuedSlices,
2045
+ runningSlices: job.runningSlices,
2046
+ exportedSlices: job.exportedSlices,
2047
+ failedSlices: job.failedSlices,
2048
+ importedPeople: job.importedPeople,
2049
+ idlePollCount
2050
+ });
2051
+ if (isSalesNavigatorCrawlJobTerminal(job.status)) {
2052
+ noMoreClaimableWork = true;
2053
+ break;
2054
+ }
2055
+ continue;
2056
+ }
2057
+ if (!shouldWaitForRemoteWork) {
2058
+ noMoreClaimableWork = true;
2059
+ }
2060
+ break;
2061
+ }
2062
+ const slice = claimed.value.slice;
2063
+ idlePollCount = 0;
2064
+ activeSlice = slice;
2065
+ const isNewSlice = !seenSliceIds.has(slice.id);
2066
+ if (isNewSlice) {
2067
+ seenSliceIds.add(slice.id);
2068
+ claimedSlices += 1;
2069
+ }
2070
+ const claimedSliceNumber = claimedSlices;
2071
+ const slot = nextSlot++;
2072
+ inFlight.set(slot, processSalesNavigatorClaimedCrawlSlice(currentSession, jobId, slice, {
2073
+ maxSplitDepth: options.maxSplitDepth,
2074
+ maxRetries: options.maxRetries,
2075
+ probeProfiles: options.probeProfiles,
2076
+ agentBusyWaitSeconds: options.agentBusyWaitSeconds,
2077
+ agentBusyMaxWaits: options.agentBusyMaxWaits,
2078
+ claimedSlices: claimedSliceNumber,
2079
+ traceId: options.traceId,
2080
+ logger: options.logger
2081
+ }).then((value) => ({ slot, value })));
1177
2082
  }
2083
+ if (inFlight.size === 0) {
2084
+ break;
2085
+ }
2086
+ const completed = await Promise.race(inFlight.values());
2087
+ inFlight.delete(completed.slot);
2088
+ currentSession = completed.value.session;
2089
+ job = completed.value.job;
2090
+ activeSlice = completed.value.activeSlice;
2091
+ lastOutcome = completed.value.lastOutcome;
1178
2092
  }
1179
2093
  if (!job) {
1180
- const status = await getSalesNavigatorCrawlStatus(currentSession, jobId);
2094
+ const status = await getSalesNavigatorCrawlStatus(currentSession, jobId, options.traceId);
1181
2095
  currentSession = status.session;
1182
2096
  job = status.value.job;
1183
2097
  }
2098
+ await options.logger?.log("salesnav.crawl.job.completed", {
2099
+ jobId,
2100
+ status: job.status,
2101
+ queuedSlices: job.queuedSlices,
2102
+ runningSlices: job.runningSlices,
2103
+ exportedSlices: job.exportedSlices,
2104
+ failedSlices: job.failedSlices,
2105
+ importedPeople: job.importedPeople,
2106
+ claimedSlices,
2107
+ truncated: claimedSlices >= options.maxSlices && (job.queuedSlices > 0 || job.runningSlices > 0),
2108
+ lastOutcome
2109
+ });
1184
2110
  return {
1185
2111
  session: currentSession,
1186
2112
  job,
@@ -1190,22 +2116,6 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
1190
2116
  lastOutcome
1191
2117
  };
1192
2118
  }
1193
- async function searchReferenceCompanyLeads(reference, icp, limit) {
1194
- if (shouldBypassAuth()) {
1195
- const fallbackTargetDomain = reference.domain ?? `${reference.slug}.com`;
1196
- const result = await leadProvider.generateLeads(icp, limit, {
1197
- companyDomain: fallbackTargetDomain,
1198
- companyName: reference.companyName
1199
- });
1200
- return result.leads;
1201
- }
1202
- const session = await requireAuthSession();
1203
- return await fetchWorkspaceLeadSearch(session, {
1204
- mode: "reference-company",
1205
- icp,
1206
- limit
1207
- });
1208
- }
1209
2119
  async function searchTargetCompanyLeads(reference, limit) {
1210
2120
  if (shouldBypassAuth()) {
1211
2121
  const fallbackTargetDomain = reference.domain ?? `${reference.slug}.com`;
@@ -1223,16 +2133,90 @@ async function searchTargetCompanyLeads(reference, limit) {
1223
2133
  limit
1224
2134
  });
1225
2135
  }
1226
- async function runReferenceCompanyWizard(rl) {
1227
- writeWizardSection("Reference company", "Paste the website or LinkedIn company page for the company you sell for.");
1228
- const reference = parseCompanyReference(await promptText(rl, "Which company are you selling for?", {
2136
+ async function runProductMarketWizard(rl) {
2137
+ writeWizardSection("Find leads from a product market", "Start from a company website, LinkedIn company page, product page, or category page. I will turn that into intended job titles and durable Sales Navigator crawls.");
2138
+ const input = await promptText(rl, "What company website or LinkedIn page should I start from?", {
2139
+ required: true
2140
+ });
2141
+ const productLimit = z.coerce.number().int().min(1).max(5000).parse(await promptText(rl, "How many products should I inspect?", { defaultValue: "25", required: true }));
2142
+ const titleLimit = z.coerce.number().int().min(1).max(1000).parse(await promptText(rl, "How many job titles should I turn into Sales Navigator crawls?", {
2143
+ defaultValue: "5",
2144
+ required: true
2145
+ }));
2146
+ writeWizardLine();
2147
+ const dryRun = shouldBypassAuth();
2148
+ if (dryRun) {
2149
+ writeWizardLine("Auth bypass is enabled, so I will preview the crawl plan instead of launching Phantombuster.");
2150
+ writeWizardLine();
2151
+ }
2152
+ const result = await runSalesNavigatorFromProductCategoryWorkflow({
2153
+ input,
2154
+ maxPages: 25,
2155
+ productLimit,
2156
+ titleLimit,
2157
+ maxResultsPerSearch: 2500,
2158
+ numberOfProfiles: 2500,
2159
+ slicePreset: "wizard-linkedin-product-category",
2160
+ maxSplitDepth: DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS.length,
2161
+ maxSlicesPerTitle: 1000,
2162
+ maxRetries: 3,
2163
+ probeProfiles: 100,
2164
+ agentBusyWaitSeconds: 30,
2165
+ agentBusyMaxWaits: 20,
2166
+ idlePollSeconds: 10,
2167
+ idleMaxPolls: 180,
2168
+ parallelExports: 3,
2169
+ skipProductUpload: false,
2170
+ dryRun
2171
+ });
2172
+ writeWizardLine(`LinkedIn product category: ${result.payload.source.category.name}.`);
2173
+ writeWizardLine(`Inspected ${result.payload.discoveredProducts} product${result.payload.discoveredProducts === 1 ? "" : "s"} and derived ${result.payload.titleCount} intended job title${result.payload.titleCount === 1 ? "" : "s"}.`);
2174
+ if (result.payload.dryRun) {
2175
+ const firstQuery = result.payload.queries?.[0];
2176
+ writeWizardLine(`Saved preview to ${result.outPath}.`);
2177
+ writeWizardLine(`Saved logs to ${result.payload.logPath}.`);
2178
+ if (firstQuery) {
2179
+ writeWizardLine(`First Sales Navigator title search: ${firstQuery.title}.`);
2180
+ }
2181
+ }
2182
+ else {
2183
+ if (result.payload.uploaded) {
2184
+ writeWizardLine(`Uploaded ${result.payload.uploaded.upserted} LinkedIn product record${result.payload.uploaded.upserted === 1 ? "" : "s"} to Salesprompter.`);
2185
+ }
2186
+ writeWizardLine(`Finished ${result.payload.crawls?.length ?? 0} durable Sales Navigator crawl${result.payload.crawls?.length === 1 ? "" : "s"}.`);
2187
+ writeWizardLine(`Imported ${result.payload.summary.totalImportedPeople} people across ${result.payload.summary.totalExportedSlices} exported slice${result.payload.summary.totalExportedSlices === 1 ? "" : "s"}.`);
2188
+ if (result.payload.summary.workflowStatus !== "completed") {
2189
+ writeWizardLine(`Some title crawls still failed: ${result.payload.summary.completedWithFailuresTitles} completed with failures, ${result.payload.summary.runningTitles} still non-terminal, ${result.payload.summary.truncatedTitles} truncated.`);
2190
+ }
2191
+ writeWizardLine(`Saved crawl summary to ${result.outPath}.`);
2192
+ writeWizardLine(`Saved logs to ${result.payload.logPath}.`);
2193
+ }
2194
+ writeWizardLine();
2195
+ writeWizardLine("Equivalent raw command:");
2196
+ const commandArgs = [
2197
+ "salesprompter",
2198
+ "salesnav:from-product-category",
2199
+ "--input",
2200
+ input,
2201
+ "--product-limit",
2202
+ String(productLimit),
2203
+ "--title-limit",
2204
+ String(titleLimit)
2205
+ ];
2206
+ if (dryRun) {
2207
+ commandArgs.push("--dry-run");
2208
+ }
2209
+ writeWizardLine(` ${buildCommandLine(commandArgs)}`);
2210
+ }
2211
+ async function runVendorShortcutWizard(rl) {
2212
+ writeWizardSection("Built-in Deel shortcut", "Use the built-in Deel ICP template and search your workspace lead data.");
2213
+ const reference = parseCompanyReference(await promptText(rl, "Which company shortcut should I use?", {
1229
2214
  required: true
1230
2215
  }));
1231
2216
  writeWizardLine();
1232
2217
  if (reference.vendorTemplate !== "deel") {
1233
- throw new Error("Automatic company-to-ICP matching is available for Deel right now. Try deel.com or the Deel LinkedIn company page.");
2218
+ throw new Error("The built-in shortcut only supports Deel right now. Use deel.com or the Deel LinkedIn company page.");
1234
2219
  }
1235
- writeWizardSection("Find matching leads", `Using the built-in ${reference.companyName} profile to search your workspace data.`);
1236
2220
  const market = await promptChoice(rl, "Where do you want to search?", [
1237
2221
  { value: "dach", label: "DACH", description: "Germany, Austria, Switzerland" },
1238
2222
  { value: "europe", label: "Europe" },
@@ -1244,7 +2228,16 @@ async function runReferenceCompanyWizard(rl) {
1244
2228
  const icpPath = `./data/${reference.slug}-icp-${market}.json`;
1245
2229
  const leadPath = buildQualifiedLeadsPath(`${reference.slug}-${market}`);
1246
2230
  await writeJsonFile(icpPath, icp);
1247
- const leads = await searchReferenceCompanyLeads(reference, icp, leadCount);
2231
+ const leads = shouldBypassAuth()
2232
+ ? (await leadProvider.generateLeads(icp, leadCount, {
2233
+ companyDomain: reference.domain ?? `${reference.slug}.com`,
2234
+ companyName: reference.companyName
2235
+ })).leads
2236
+ : await fetchWorkspaceLeadSearch(await requireAuthSession(), {
2237
+ mode: "reference-company",
2238
+ icp,
2239
+ limit: leadCount
2240
+ });
1248
2241
  await writeJsonFile(leadPath, leads);
1249
2242
  writeWizardLine(`Saved ICP to ${icpPath}.`);
1250
2243
  if (leads.length === 0) {
@@ -1342,7 +2335,7 @@ async function runWizard(options) {
1342
2335
  throw new Error("wizard does not support --json or --quiet.");
1343
2336
  }
1344
2337
  writeWizardLine("Salesprompter");
1345
- writeWizardLine("Start with a company website or LinkedIn page. I will guide you from there.");
2338
+ writeWizardLine("Start with a company website, LinkedIn product page, or category URL. I will guide you from there.");
1346
2339
  writeWizardLine();
1347
2340
  await ensureWizardSession(options);
1348
2341
  const rl = createInterface({
@@ -1351,11 +2344,17 @@ async function runWizard(options) {
1351
2344
  });
1352
2345
  try {
1353
2346
  const flow = await promptChoice(rl, "What do you want help with?", [
2347
+ {
2348
+ value: "product-market",
2349
+ label: "Find leads from a product market",
2350
+ description: "Start from a company, product, or LinkedIn category and crawl Sales Navigator",
2351
+ aliases: ["product market", "linkedin products", "category", "sales navigator", "crawl"]
2352
+ },
1354
2353
  {
1355
2354
  value: "reference-company",
1356
- label: "Find leads like one of my customers",
1357
- description: "Example: I sell for Deel and want similar companies and people",
1358
- aliases: ["customer", "reference company", "similar companies", "icp", "who to target"]
2355
+ label: "Use the built-in Deel shortcut",
2356
+ description: "Generate the saved Deel ICP and search workspace leads",
2357
+ aliases: ["deel", "shortcut", "vendor template", "quick deel"]
1359
2358
  },
1360
2359
  {
1361
2360
  value: "target-company",
@@ -1369,10 +2368,14 @@ async function runWizard(options) {
1369
2368
  description: "Use a saved leads file to fill an Instantly campaign",
1370
2369
  aliases: ["instantly", "outreach", "send leads", "campaign"]
1371
2370
  }
1372
- ], "reference-company");
2371
+ ], "product-market");
1373
2372
  writeWizardLine();
2373
+ if (flow === "product-market") {
2374
+ await runProductMarketWizard(rl);
2375
+ return;
2376
+ }
1374
2377
  if (flow === "reference-company") {
1375
- await runReferenceCompanyWizard(rl);
2378
+ await runVendorShortcutWizard(rl);
1376
2379
  return;
1377
2380
  }
1378
2381
  if (flow === "target-company") {
@@ -1521,7 +2524,7 @@ async function fetchHistoricalQueryRows(tables) {
1521
2524
  }
1522
2525
  program
1523
2526
  .name("salesprompter")
1524
- .description("Sales workflow CLI for ICP definition, lead generation, enrichment, scoring, and sync.")
2527
+ .description("Sales workflow CLI for LinkedIn product discovery, Sales Navigator crawling, lead enrichment, scoring, and sync.")
1525
2528
  .version(packageVersion)
1526
2529
  .option("--json", "Emit compact machine-readable JSON output", false)
1527
2530
  .option("--quiet", "Suppress successful stdout output", false);
@@ -1862,6 +2865,219 @@ program
1862
2865
  uploaded
1863
2866
  });
1864
2867
  });
2868
+ program
2869
+ .command("salesnav:from-product-category")
2870
+ .description("Crawl a LinkedIn product category, derive intended-role title searches, then run durable Sales Navigator crawls that export through Phantombuster into Salesprompter.")
2871
+ .requiredOption("--input <value>", "Company domain, LinkedIn company URL, LinkedIn product URL, LinkedIn category URL, or LinkedIn product search URL")
2872
+ .option("--max-pages <number>", "Maximum LinkedIn category pages to fetch", "25")
2873
+ .option("--product-limit <number>", "Optional cap on the number of LinkedIn products to inspect")
2874
+ .option("--title-limit <number>", "Optional cap on the number of intended-role titles to crawl")
2875
+ .option("--max-results-per-search <number>", "Maximum Sales Navigator results allowed for one slice before splitting again. Current live export cap is 2500.", "2500")
2876
+ .option("--number-of-profiles <number>", "Profiles to request from Phantombuster per finished Sales Navigator slice. Current live export cap is 2500.", "2500")
2877
+ .option("--slice-preset <name>", "Slice preset label stored with every durable crawl job", "linkedin-product-category")
2878
+ .option("--max-split-depth <number>", "Maximum number of adaptive split dimensions to use", "6")
2879
+ .option("--max-slices-per-title <number>", "Safety cap for total claimed slices per intended-role title", "1000")
2880
+ .option("--max-retries <number>", "Retries for non-splitting export failures", "3")
2881
+ .option("--probe-profiles <number>", "Profiles to scrape while probing whether a slice is still too broad", "100")
2882
+ .option("--agent-busy-wait-seconds <number>", "Seconds to wait before retrying when the export agent is already busy", "30")
2883
+ .option("--agent-busy-max-waits <number>", "How many busy-agent waits to tolerate before failing the slice", "20")
2884
+ .option("--idle-poll-seconds <number>", "Seconds to wait before polling durable crawl status when remote slices are still running", "10")
2885
+ .option("--idle-max-polls <number>", "How many no-claim status polls to tolerate before the crawl is considered stalled", "180")
2886
+ .option("--parallel-exports <number>", "How many Sales Navigator slices to export concurrently per title crawl", "3")
2887
+ .option("--allow-partial-success", "Exit 0 even when one or more durable title crawls finish with failures", false)
2888
+ .option("--skip-product-upload", "Do not upload the crawled LinkedIn product catalog before starting Sales Navigator crawls", false)
2889
+ .option("--out <path>", "Optional local JSON output path")
2890
+ .option("--log-path <path>", "Optional JSONL log path with timestamps, trace id, and Sales Navigator query metadata")
2891
+ .option("--dry-run", "Preview the derived intended-role title queries without creating crawl jobs", false)
2892
+ .action(async (options) => {
2893
+ const maxPages = z.coerce.number().int().min(1).max(500).parse(options.maxPages);
2894
+ const productLimit = options.productLimit === undefined
2895
+ ? undefined
2896
+ : z.coerce.number().int().min(1).max(5000).parse(options.productLimit);
2897
+ const titleLimit = options.titleLimit === undefined
2898
+ ? undefined
2899
+ : z.coerce.number().int().min(1).max(1000).parse(options.titleLimit);
2900
+ const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
2901
+ const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
2902
+ const maxSplitDepth = z.coerce.number().int().min(1).max(6).parse(options.maxSplitDepth);
2903
+ const maxSlicesPerTitle = z.coerce.number().int().min(1).max(10000).parse(options.maxSlicesPerTitle);
2904
+ const maxRetries = z.coerce.number().int().min(0).max(5).parse(options.maxRetries);
2905
+ const probeProfiles = z.coerce.number().int().min(1).max(2500).parse(options.probeProfiles);
2906
+ const agentBusyWaitSeconds = z.coerce.number().int().min(1).max(300).parse(options.agentBusyWaitSeconds);
2907
+ const agentBusyMaxWaits = z.coerce.number().int().min(0).max(120).parse(options.agentBusyMaxWaits);
2908
+ const idlePollSeconds = z.coerce.number().int().min(0).max(300).parse(options.idlePollSeconds);
2909
+ const idleMaxPolls = z.coerce.number().int().min(0).max(10000).parse(options.idleMaxPolls);
2910
+ const parallelExports = z.coerce.number().int().min(1).max(10).parse(options.parallelExports);
2911
+ const result = await runSalesNavigatorFromProductCategoryWorkflow({
2912
+ input: options.input,
2913
+ maxPages,
2914
+ productLimit,
2915
+ titleLimit,
2916
+ maxResultsPerSearch,
2917
+ numberOfProfiles,
2918
+ slicePreset: options.slicePreset,
2919
+ maxSplitDepth,
2920
+ maxSlicesPerTitle,
2921
+ maxRetries,
2922
+ probeProfiles,
2923
+ agentBusyWaitSeconds,
2924
+ agentBusyMaxWaits,
2925
+ idlePollSeconds,
2926
+ idleMaxPolls,
2927
+ parallelExports,
2928
+ skipProductUpload: Boolean(options.skipProductUpload),
2929
+ outPath: options.out,
2930
+ logPath: options.logPath,
2931
+ dryRun: Boolean(options.dryRun || shouldBypassAuth())
2932
+ });
2933
+ printOutput({
2934
+ ...result.payload,
2935
+ out: result.outPath
2936
+ });
2937
+ if (!result.payload.dryRun && result.payload.summary.workflowStatus !== "completed" && !options.allowPartialSuccess) {
2938
+ throw new Error(buildSalesNavigatorWorkflowFailureMessage(result.payload.summary));
2939
+ }
2940
+ });
2941
+ program
2942
+ .command("salesnav:ensure-count")
2943
+ .description("Ensure the workspace has at least the target number of Sales Navigator people rows by importing historical BigQuery windows directly.")
2944
+ .option("--target-count <number>", "Minimum linkedin_sales_nav_people rows to guarantee", "200000")
2945
+ .option("--scope <scope>", "Historical scope: all-sales-people|hr-function-included", "all-sales-people")
2946
+ .option("--org-id <id>", "Workspace org id. Defaults to the active CLI org.")
2947
+ .option("--start-offset <number>", "BigQuery offset override. By default the CLI resumes from prior historical backfill runs.")
2948
+ .option("--window-size <number>", "How many historical contacts to request from BigQuery per window", String(salesNavigatorHistoricalBackfillDefaults.windowSize))
2949
+ .option("--max-windows <number>", "Maximum number of BigQuery windows to import in this invocation", "10")
2950
+ .option("--page-size <number>", "BigQuery page size per API read", String(salesNavigatorHistoricalBackfillDefaults.pageSize))
2951
+ .option("--upsert-batch-size <number>", "Supabase upsert batch size", String(salesNavigatorHistoricalBackfillDefaults.upsertBatchSize))
2952
+ .option("--min-upsert-batch-size <number>", "Smallest batch size allowed after timeout-driven splitting", String(salesNavigatorHistoricalBackfillDefaults.minUpsertBatchSize))
2953
+ .option("--max-upsert-retries <number>", "How many retry rounds to tolerate for timeout-prone writes", String(salesNavigatorHistoricalBackfillDefaults.maxUpsertRetries))
2954
+ .option("--retry-delay-ms <number>", "Base retry delay in milliseconds for write retries", String(salesNavigatorHistoricalBackfillDefaults.retryDelayMs))
2955
+ .option("--out <path>", "Optional local JSON output path")
2956
+ .option("--dry-run", "Preview the historical import plan without touching BigQuery or Supabase", false)
2957
+ .action(async (options) => {
2958
+ const targetCount = z.coerce.number().int().min(1).parse(options.targetCount);
2959
+ const scope = z.enum(["all-sales-people", "hr-function-included"]).parse(options.scope);
2960
+ const explicitStartOffset = typeof options.startOffset === "string" && options.startOffset.trim().length > 0
2961
+ ? z.coerce.number().int().min(0).parse(options.startOffset)
2962
+ : null;
2963
+ const windowSize = z.coerce.number().int().min(1).parse(options.windowSize);
2964
+ const maxWindows = z.coerce.number().int().min(1).max(100).parse(options.maxWindows);
2965
+ const pageSize = z.coerce.number().int().min(1).parse(options.pageSize);
2966
+ const upsertBatchSize = z.coerce.number().int().min(1).parse(options.upsertBatchSize);
2967
+ const minUpsertBatchSize = z.coerce.number().int().min(1).parse(options.minUpsertBatchSize);
2968
+ const maxUpsertRetries = z.coerce.number().int().min(0).parse(options.maxUpsertRetries);
2969
+ const retryDelayMs = z.coerce.number().int().min(0).parse(options.retryDelayMs);
2970
+ if (minUpsertBatchSize > upsertBatchSize) {
2971
+ throw new Error("--min-upsert-batch-size must be less than or equal to --upsert-batch-size.");
2972
+ }
2973
+ if (Boolean(options.dryRun)) {
2974
+ const plan = buildSalesNavigatorHistoricalBackfillPlan({
2975
+ targetCount,
2976
+ currentCount: null,
2977
+ startOffset: explicitStartOffset ?? 0,
2978
+ windowSize,
2979
+ maxWindows
2980
+ });
2981
+ const payload = {
2982
+ status: "ok",
2983
+ dryRun: true,
2984
+ mode: "historical-bigquery-backfill",
2985
+ orgId: options.orgId ?? null,
2986
+ scope,
2987
+ targetCount,
2988
+ resumedFromHistory: false,
2989
+ plan
2990
+ };
2991
+ if (options.out) {
2992
+ await writeJsonFile(options.out, payload);
2993
+ }
2994
+ printOutput(payload);
2995
+ return;
2996
+ }
2997
+ let sessionOrgId = null;
2998
+ if (!shouldBypassAuth()) {
2999
+ const session = await requireAuthSession();
3000
+ sessionOrgId = session.user.orgId ?? null;
3001
+ }
3002
+ const orgId = resolveSalesNavigatorHistoricalBackfillOrgId({
3003
+ explicitOrgId: options.orgId,
3004
+ env: process.env,
3005
+ sessionOrgId
3006
+ });
3007
+ const config = resolveSalesNavigatorHistoricalBackfillConfig(process.env);
3008
+ const supabase = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
3009
+ auth: { persistSession: false }
3010
+ });
3011
+ const resumeState = explicitStartOffset === null
3012
+ ? await resolveSalesNavigatorHistoricalBackfillResumeState({
3013
+ supabase,
3014
+ orgId,
3015
+ scope,
3016
+ windowSize,
3017
+ fallbackOffset: 0
3018
+ })
3019
+ : {
3020
+ startOffset: explicitStartOffset,
3021
+ resumedFromHistory: false,
3022
+ matchedHistoryRows: 0,
3023
+ reason: "fallback"
3024
+ };
3025
+ if (resumeState.resumedFromHistory) {
3026
+ writeProgress(`Resuming historical Sales Navigator backfill from offset ${resumeState.startOffset} based on prior CLI runs.`);
3027
+ }
3028
+ else if (explicitStartOffset !== null) {
3029
+ writeProgress(`Using explicit historical Sales Navigator backfill offset ${explicitStartOffset}.`);
3030
+ }
3031
+ const summary = await ensureSalesNavigatorPeopleCount({
3032
+ config,
3033
+ orgId,
3034
+ targetCount,
3035
+ scope,
3036
+ startOffset: resumeState.startOffset,
3037
+ resumedFromHistory: resumeState.resumedFromHistory,
3038
+ windowSize,
3039
+ maxWindows,
3040
+ pageSize,
3041
+ upsertBatchSize,
3042
+ minUpsertBatchSize,
3043
+ maxUpsertRetries,
3044
+ retryDelayMs,
3045
+ onProgress: (event) => {
3046
+ if (event.type === "window-start") {
3047
+ writeProgress(`Starting historical Sales Navigator backfill window ${event.windowIndex + 1}: offset ${event.offset}, limit ${event.limit}.`);
3048
+ return;
3049
+ }
3050
+ if (event.type === "window-progress") {
3051
+ writeProgress(`Historical window ${event.windowIndex + 1}: ${event.processed}/${event.totalResults} rows imported (${event.percent}%).`);
3052
+ return;
3053
+ }
3054
+ writeProgress(`Historical window ${event.windowIndex + 1} complete: count ${event.countBefore} -> ${event.countAfter} (delta ${event.countDelta}).`);
3055
+ }
3056
+ });
3057
+ const payload = {
3058
+ status: summary.status,
3059
+ dryRun: false,
3060
+ mode: "historical-bigquery-backfill",
3061
+ orgId: summary.orgId,
3062
+ scope: summary.scope,
3063
+ targetCount: summary.targetCount,
3064
+ initialCount: summary.initialCount,
3065
+ currentCount: summary.currentCount,
3066
+ resumedFromHistory: summary.resumedFromHistory,
3067
+ startOffset: summary.startOffset,
3068
+ nextOffset: summary.nextOffset,
3069
+ exhausted: summary.exhausted,
3070
+ completedWindows: summary.completedWindows,
3071
+ windows: summary.windows
3072
+ };
3073
+ if (options.out) {
3074
+ await writeJsonFile(options.out, payload);
3075
+ }
3076
+ if (summary.status !== "ok") {
3077
+ throw new Error(`Historical Sales Navigator backfill stopped at ${summary.currentCount} rows before reaching the target ${summary.targetCount}.`);
3078
+ }
3079
+ printOutput(payload);
3080
+ });
1865
3081
  program
1866
3082
  .command("salesnav:crawl")
1867
3083
  .description("Adaptively split broad LinkedIn Sales Navigator people searches into exportable slices and store every finished slice through Salesprompter.")
@@ -1876,7 +3092,12 @@ program
1876
3092
  .option("--probe-profiles <number>", "Profiles to scrape while probing whether a slice is still too broad", "100")
1877
3093
  .option("--agent-busy-wait-seconds <number>", "Seconds to wait before retrying when the export agent is already busy", "30")
1878
3094
  .option("--agent-busy-max-waits <number>", "How many busy-agent waits to tolerate before failing the slice", "20")
3095
+ .option("--idle-poll-seconds <number>", "Seconds to wait before polling durable crawl status when remote slices are still running", "10")
3096
+ .option("--idle-max-polls <number>", "How many no-claim status polls to tolerate before the crawl is considered stalled", "180")
3097
+ .option("--parallel-exports <number>", "How many Sales Navigator slices to export concurrently in this invocation", "3")
3098
+ .option("--allow-partial-success", "Exit 0 even when the durable crawl finishes with failures or remains non-terminal", false)
1879
3099
  .option("--out <path>", "Optional local JSON output path")
3100
+ .option("--log-path <path>", "Optional JSONL log path with timestamps, trace id, and Sales Navigator slice metadata")
1880
3101
  .option("--dry-run", "Preview the adaptive crawl plan without exporting anything", false)
1881
3102
  .action(async (options) => {
1882
3103
  const queryUrl = z.string().url().optional().parse(options.queryUrl);
@@ -1889,7 +3110,30 @@ program
1889
3110
  const probeProfiles = z.coerce.number().int().min(1).max(2500).parse(options.probeProfiles);
1890
3111
  const agentBusyWaitSeconds = z.coerce.number().int().min(1).max(300).parse(options.agentBusyWaitSeconds);
1891
3112
  const agentBusyMaxWaits = z.coerce.number().int().min(0).max(120).parse(options.agentBusyMaxWaits);
3113
+ const idlePollSeconds = z.coerce.number().int().min(0).max(300).parse(options.idlePollSeconds);
3114
+ const idleMaxPolls = z.coerce.number().int().min(0).max(10000).parse(options.idleMaxPolls);
3115
+ const parallelExports = z.coerce.number().int().min(1).max(10).parse(options.parallelExports);
1892
3116
  const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
3117
+ const logger = await createWorkflowLogger({
3118
+ logPath: options.logPath ?? buildSalesNavigatorCrawlLogPath(jobId ?? queryUrl ?? "salesnav-crawl")
3119
+ });
3120
+ await logger.log("salesnav.crawl.command.started", {
3121
+ queryUrl: queryUrl ?? null,
3122
+ jobId: jobId ?? null,
3123
+ maxResultsPerSearch,
3124
+ numberOfProfiles,
3125
+ slicePreset: options.slicePreset,
3126
+ maxSplitDepth,
3127
+ maxSlices,
3128
+ maxRetries,
3129
+ probeProfiles,
3130
+ agentBusyWaitSeconds,
3131
+ agentBusyMaxWaits,
3132
+ idlePollSeconds,
3133
+ idleMaxPolls,
3134
+ parallelExports,
3135
+ dryRun: effectiveDryRun
3136
+ });
1893
3137
  if (effectiveDryRun) {
1894
3138
  if (jobId) {
1895
3139
  throw new Error("--dry-run does not support --job-id. Use --query-url instead.");
@@ -1901,6 +3145,8 @@ program
1901
3145
  status: "ok",
1902
3146
  dryRun: true,
1903
3147
  mode: "adaptive",
3148
+ traceId: logger.traceId,
3149
+ logPath: logger.logPath,
1904
3150
  dimensionPreset: "human-resources-adaptive",
1905
3151
  query: (() => {
1906
3152
  const preview = buildSalesNavigatorCrawlPreview({
@@ -1930,6 +3176,15 @@ program
1930
3176
  };
1931
3177
  })()
1932
3178
  };
3179
+ await logger.log("salesnav.crawl.dry-run.preview", {
3180
+ sourceQueryUrl: payload.query.sourceQueryUrl,
3181
+ root: summarizeSalesNavigatorQuery(payload.query.rootQueryUrl, payload.query.rootAppliedFilters),
3182
+ dimensionOrder: payload.query.dimensionOrder,
3183
+ firstSplitQueries: payload.query.firstSplitQueries.map((attempt) => ({
3184
+ splitTrail: attempt.splitTrail,
3185
+ ...summarizeSalesNavigatorQuery(attempt.slicedQueryUrl, attempt.appliedFilters)
3186
+ }))
3187
+ });
1933
3188
  if (options.out) {
1934
3189
  await writeJsonFile(options.out, payload);
1935
3190
  }
@@ -1954,23 +3209,57 @@ program
1954
3209
  slicePreset: options.slicePreset,
1955
3210
  maxResultsPerSearch,
1956
3211
  numberOfProfiles,
3212
+ rawPayload: {
3213
+ workflow: "salesnav:crawl",
3214
+ traceId: logger.traceId,
3215
+ command: {
3216
+ sourceQueryUrl: queryUrl,
3217
+ slicePreset: options.slicePreset,
3218
+ maxResultsPerSearch,
3219
+ numberOfProfiles,
3220
+ maxSplitDepth,
3221
+ maxSlices,
3222
+ maxRetries,
3223
+ probeProfiles,
3224
+ agentBusyWaitSeconds,
3225
+ agentBusyMaxWaits,
3226
+ idlePollSeconds,
3227
+ idleMaxPolls,
3228
+ parallelExports
3229
+ }
3230
+ },
1957
3231
  rootSlice: {
1958
3232
  slicedQueryUrl: seed.slicedQueryUrl,
1959
3233
  appliedFilters: seed.appliedFilters,
1960
3234
  depth: seed.depth,
1961
- splitTrail: seed.splitTrail
3235
+ splitTrail: seed.splitTrail,
3236
+ rawPayload: {
3237
+ workflow: "salesnav:crawl",
3238
+ traceId: logger.traceId
3239
+ }
1962
3240
  }
1963
- });
3241
+ }, logger.traceId);
1964
3242
  session = created.session;
1965
3243
  createResult = {
1966
3244
  resumed: created.value.resumed,
1967
3245
  job: created.value.job
1968
3246
  };
1969
3247
  resolvedJobId = created.value.job.id;
3248
+ await logger.log("salesnav.crawl.job.ready", {
3249
+ jobId: resolvedJobId,
3250
+ resumed: created.value.resumed,
3251
+ sourceQueryUrl: queryUrl,
3252
+ rootSlice: summarizeSalesNavigatorQuery(seed.slicedQueryUrl, seed.appliedFilters)
3253
+ });
1970
3254
  }
1971
3255
  else {
1972
- const status = await getSalesNavigatorCrawlStatus(session, resolvedJobId);
3256
+ const status = await getSalesNavigatorCrawlStatus(session, resolvedJobId, logger.traceId);
1973
3257
  session = status.session;
3258
+ await logger.log("salesnav.crawl.job.resumed", {
3259
+ jobId: resolvedJobId,
3260
+ sourceQueryUrl: status.value.job.sourceQueryUrl,
3261
+ status: status.value.job.status
3262
+ });
1974
3263
  }
1975
3264
  if (!resolvedJobId) {
1976
3265
  throw new Error("Failed to determine Sales Navigator crawl job id.");
@@ -1981,12 +3270,19 @@ program
1981
3270
  maxRetries,
1982
3271
  probeProfiles,
1983
3272
  agentBusyWaitSeconds,
1984
- agentBusyMaxWaits
3273
+ agentBusyMaxWaits,
3274
+ idlePollSeconds,
3275
+ idleMaxPolls,
3276
+ parallelExports,
3277
+ traceId: logger.traceId,
3278
+ logger
1985
3279
  });
1986
3280
  const payload = {
1987
3281
  status: "ok",
1988
3282
  dryRun: false,
1989
3283
  mode: "durable",
3284
+ traceId: logger.traceId,
3285
+ logPath: logger.logPath,
1990
3286
  jobId: resolvedJobId,
1991
3287
  resumed: createResult?.resumed ?? true,
1992
3288
  sourceQueryUrl: crawl.job.sourceQueryUrl,
@@ -2009,10 +3305,20 @@ program
2009
3305
  : null,
2010
3306
  lastOutcome: crawl.lastOutcome
2011
3307
  };
3308
+ await logger.log("salesnav.crawl.command.completed", {
3309
+ jobId: resolvedJobId,
3310
+ status: crawl.job.status,
3311
+ claimedSlices: crawl.claimedSlices,
3312
+ truncated: crawl.truncated,
3313
+ lastOutcome: crawl.lastOutcome
3314
+ });
2012
3315
  if (options.out) {
2013
3316
  await writeJsonFile(options.out, payload);
2014
3317
  }
2015
3318
  printOutput(payload);
3319
+ if ((crawl.job.status !== "completed" || crawl.truncated) && !options.allowPartialSuccess) {
3320
+ throw new Error(`Sales Navigator crawl did not finish cleanly. status=${crawl.job.status} truncated=${crawl.truncated} failedSlices=${crawl.job.failedSlices} runningSlices=${crawl.job.runningSlices} queuedSlices=${crawl.job.queuedSlices}`);
3321
+ }
2016
3322
  });
2017
3323
  program
2018
3324
  .command("salesnav:crawl:status")
@@ -2073,7 +3379,13 @@ program
2073
3379
  appliedFilters: item.appliedFilters,
2074
3380
  maxResultsPerSearch,
2075
3381
  numberOfProfiles,
2076
- slicePreset: options.slicePreset
3382
+ slicePreset: options.slicePreset,
3383
+ rawPayload: {
3384
+ workflow: "salesnav:export",
3385
+ sourceQueryUrl: item.sourceQueryUrl,
3386
+ slicedQueryUrl: item.slicedQueryUrl,
3387
+ appliedFilters: item.appliedFilters
3388
+ }
2077
3389
  });
2078
3390
  exported.push(result);
2079
3391
  }