salesprompter-cli 0.1.19 → 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
+ };
1288
+ }
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
+ }
795
1407
  }
796
- async function startSalesNavigatorExport(session, payload) {
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,81 +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
- const seenSliceIds = new Set();
1121
- let activeSlice = null;
1122
- let job = null;
1123
- let lastOutcome = null;
1124
- while (true) {
1125
- if (claimedSlices >= options.maxSlices && lastOutcome?.outcome !== "retryable_failed") {
1126
- break;
1127
- }
1128
- const claimed = await claimNextSalesNavigatorCrawlSlice(currentSession, jobId);
1129
- currentSession = claimed.session;
1130
- job = claimed.value.job;
1131
- if (!claimed.value.slice) {
1132
- break;
1133
- }
1134
- const slice = claimed.value.slice;
1135
- activeSlice = slice;
1136
- const isNewSlice = !seenSliceIds.has(slice.id);
1137
- if (isNewSlice) {
1138
- seenSliceIds.add(slice.id);
1139
- claimedSlices += 1;
1140
- }
1141
- if (!runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
1142
- process.stderr.write(`Processing Sales Navigator slice ${claimedSlices}/${options.maxSlices}: ${slice.slicedQueryUrl}\n`);
1143
- }
1144
- try {
1145
- const result = await runSalesNavigatorCrawlAttempt(currentSession, buildSalesNavigatorCrawlAttemptFromClaimedSlice(slice), {
1146
- maxSplitDepth: options.maxSplitDepth,
1147
- probeProfiles: options.probeProfiles,
1148
- agentBusyWaitSeconds: options.agentBusyWaitSeconds,
1149
- agentBusyMaxWaits: options.agentBusyMaxWaits
1150
- }, {
1151
- crawlJobId: jobId,
1152
- crawlSliceId: slice.id
1153
- });
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);
1154
1836
  const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, {
1155
1837
  sliceId: slice.id,
1156
- outcome: "exported",
1157
- totalResults: result.totalResults ?? null,
1158
- exportRunId: result.runId,
1159
- importedPeople: result.imported,
1160
- upsertedPeople: result.upserted
1161
- });
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);
1162
1850
  currentSession = reported.session;
1163
- job = reported.value.job;
1164
- 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: {
1165
1927
  outcome: "exported",
1166
1928
  runId: result.runId,
1167
1929
  totalResults: result.totalResults ?? null
1168
- };
1169
- }
1170
- catch (error) {
1171
- const payload = buildSalesNavigatorSliceFailureReport(slice, error, {
1172
- maxSplitDepth: options.maxSplitDepth,
1173
- maxRetries: options.maxRetries
1174
- });
1175
- const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, payload);
1176
- currentSession = reported.session;
1177
- job = reported.value.job;
1178
- 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: {
1179
1974
  outcome: payload.outcome,
1180
1975
  runId: payload.exportRunId,
1181
1976
  error: payload.error,
1182
1977
  errorCode: payload.errorCode,
1183
1978
  totalResults: payload.totalResults
1184
- };
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 })));
2082
+ }
2083
+ if (inFlight.size === 0) {
2084
+ break;
1185
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;
1186
2092
  }
1187
2093
  if (!job) {
1188
- const status = await getSalesNavigatorCrawlStatus(currentSession, jobId);
2094
+ const status = await getSalesNavigatorCrawlStatus(currentSession, jobId, options.traceId);
1189
2095
  currentSession = status.session;
1190
2096
  job = status.value.job;
1191
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
+ });
1192
2110
  return {
1193
2111
  session: currentSession,
1194
2112
  job,
@@ -1198,22 +2116,6 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
1198
2116
  lastOutcome
1199
2117
  };
1200
2118
  }
1201
- async function searchReferenceCompanyLeads(reference, icp, limit) {
1202
- if (shouldBypassAuth()) {
1203
- const fallbackTargetDomain = reference.domain ?? `${reference.slug}.com`;
1204
- const result = await leadProvider.generateLeads(icp, limit, {
1205
- companyDomain: fallbackTargetDomain,
1206
- companyName: reference.companyName
1207
- });
1208
- return result.leads;
1209
- }
1210
- const session = await requireAuthSession();
1211
- return await fetchWorkspaceLeadSearch(session, {
1212
- mode: "reference-company",
1213
- icp,
1214
- limit
1215
- });
1216
- }
1217
2119
  async function searchTargetCompanyLeads(reference, limit) {
1218
2120
  if (shouldBypassAuth()) {
1219
2121
  const fallbackTargetDomain = reference.domain ?? `${reference.slug}.com`;
@@ -1231,16 +2133,90 @@ async function searchTargetCompanyLeads(reference, limit) {
1231
2133
  limit
1232
2134
  });
1233
2135
  }
1234
- async function runReferenceCompanyWizard(rl) {
1235
- writeWizardSection("Reference company", "Paste the website or LinkedIn company page for the company you sell for.");
1236
- 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?", {
1237
2214
  required: true
1238
2215
  }));
1239
2216
  writeWizardLine();
1240
2217
  if (reference.vendorTemplate !== "deel") {
1241
- 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.");
1242
2219
  }
1243
- writeWizardSection("Find matching leads", `Using the built-in ${reference.companyName} profile to search your workspace data.`);
1244
2220
  const market = await promptChoice(rl, "Where do you want to search?", [
1245
2221
  { value: "dach", label: "DACH", description: "Germany, Austria, Switzerland" },
1246
2222
  { value: "europe", label: "Europe" },
@@ -1252,7 +2228,16 @@ async function runReferenceCompanyWizard(rl) {
1252
2228
  const icpPath = `./data/${reference.slug}-icp-${market}.json`;
1253
2229
  const leadPath = buildQualifiedLeadsPath(`${reference.slug}-${market}`);
1254
2230
  await writeJsonFile(icpPath, icp);
1255
- 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
+ });
1256
2241
  await writeJsonFile(leadPath, leads);
1257
2242
  writeWizardLine(`Saved ICP to ${icpPath}.`);
1258
2243
  if (leads.length === 0) {
@@ -1350,7 +2335,7 @@ async function runWizard(options) {
1350
2335
  throw new Error("wizard does not support --json or --quiet.");
1351
2336
  }
1352
2337
  writeWizardLine("Salesprompter");
1353
- 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.");
1354
2339
  writeWizardLine();
1355
2340
  await ensureWizardSession(options);
1356
2341
  const rl = createInterface({
@@ -1359,11 +2344,17 @@ async function runWizard(options) {
1359
2344
  });
1360
2345
  try {
1361
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
+ },
1362
2353
  {
1363
2354
  value: "reference-company",
1364
- label: "Find leads like one of my customers",
1365
- description: "Example: I sell for Deel and want similar companies and people",
1366
- 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"]
1367
2358
  },
1368
2359
  {
1369
2360
  value: "target-company",
@@ -1377,10 +2368,14 @@ async function runWizard(options) {
1377
2368
  description: "Use a saved leads file to fill an Instantly campaign",
1378
2369
  aliases: ["instantly", "outreach", "send leads", "campaign"]
1379
2370
  }
1380
- ], "reference-company");
2371
+ ], "product-market");
1381
2372
  writeWizardLine();
2373
+ if (flow === "product-market") {
2374
+ await runProductMarketWizard(rl);
2375
+ return;
2376
+ }
1382
2377
  if (flow === "reference-company") {
1383
- await runReferenceCompanyWizard(rl);
2378
+ await runVendorShortcutWizard(rl);
1384
2379
  return;
1385
2380
  }
1386
2381
  if (flow === "target-company") {
@@ -1529,7 +2524,7 @@ async function fetchHistoricalQueryRows(tables) {
1529
2524
  }
1530
2525
  program
1531
2526
  .name("salesprompter")
1532
- .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.")
1533
2528
  .version(packageVersion)
1534
2529
  .option("--json", "Emit compact machine-readable JSON output", false)
1535
2530
  .option("--quiet", "Suppress successful stdout output", false);
@@ -1870,6 +2865,219 @@ program
1870
2865
  uploaded
1871
2866
  });
1872
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
+ });
1873
3081
  program
1874
3082
  .command("salesnav:crawl")
1875
3083
  .description("Adaptively split broad LinkedIn Sales Navigator people searches into exportable slices and store every finished slice through Salesprompter.")
@@ -1884,7 +3092,12 @@ program
1884
3092
  .option("--probe-profiles <number>", "Profiles to scrape while probing whether a slice is still too broad", "100")
1885
3093
  .option("--agent-busy-wait-seconds <number>", "Seconds to wait before retrying when the export agent is already busy", "30")
1886
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)
1887
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")
1888
3101
  .option("--dry-run", "Preview the adaptive crawl plan without exporting anything", false)
1889
3102
  .action(async (options) => {
1890
3103
  const queryUrl = z.string().url().optional().parse(options.queryUrl);
@@ -1897,7 +3110,30 @@ program
1897
3110
  const probeProfiles = z.coerce.number().int().min(1).max(2500).parse(options.probeProfiles);
1898
3111
  const agentBusyWaitSeconds = z.coerce.number().int().min(1).max(300).parse(options.agentBusyWaitSeconds);
1899
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);
1900
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
+ });
1901
3137
  if (effectiveDryRun) {
1902
3138
  if (jobId) {
1903
3139
  throw new Error("--dry-run does not support --job-id. Use --query-url instead.");
@@ -1909,6 +3145,8 @@ program
1909
3145
  status: "ok",
1910
3146
  dryRun: true,
1911
3147
  mode: "adaptive",
3148
+ traceId: logger.traceId,
3149
+ logPath: logger.logPath,
1912
3150
  dimensionPreset: "human-resources-adaptive",
1913
3151
  query: (() => {
1914
3152
  const preview = buildSalesNavigatorCrawlPreview({
@@ -1938,6 +3176,15 @@ program
1938
3176
  };
1939
3177
  })()
1940
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
+ });
1941
3188
  if (options.out) {
1942
3189
  await writeJsonFile(options.out, payload);
1943
3190
  }
@@ -1962,23 +3209,57 @@ program
1962
3209
  slicePreset: options.slicePreset,
1963
3210
  maxResultsPerSearch,
1964
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
+ },
1965
3231
  rootSlice: {
1966
3232
  slicedQueryUrl: seed.slicedQueryUrl,
1967
3233
  appliedFilters: seed.appliedFilters,
1968
3234
  depth: seed.depth,
1969
- splitTrail: seed.splitTrail
3235
+ splitTrail: seed.splitTrail,
3236
+ rawPayload: {
3237
+ workflow: "salesnav:crawl",
3238
+ traceId: logger.traceId
3239
+ }
1970
3240
  }
1971
- });
3241
+ }, logger.traceId);
1972
3242
  session = created.session;
1973
3243
  createResult = {
1974
3244
  resumed: created.value.resumed,
1975
3245
  job: created.value.job
1976
3246
  };
1977
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
+ });
1978
3254
  }
1979
3255
  else {
1980
- const status = await getSalesNavigatorCrawlStatus(session, resolvedJobId);
3256
+ const status = await getSalesNavigatorCrawlStatus(session, resolvedJobId, logger.traceId);
1981
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
+ });
1982
3263
  }
1983
3264
  if (!resolvedJobId) {
1984
3265
  throw new Error("Failed to determine Sales Navigator crawl job id.");
@@ -1989,12 +3270,19 @@ program
1989
3270
  maxRetries,
1990
3271
  probeProfiles,
1991
3272
  agentBusyWaitSeconds,
1992
- agentBusyMaxWaits
3273
+ agentBusyMaxWaits,
3274
+ idlePollSeconds,
3275
+ idleMaxPolls,
3276
+ parallelExports,
3277
+ traceId: logger.traceId,
3278
+ logger
1993
3279
  });
1994
3280
  const payload = {
1995
3281
  status: "ok",
1996
3282
  dryRun: false,
1997
3283
  mode: "durable",
3284
+ traceId: logger.traceId,
3285
+ logPath: logger.logPath,
1998
3286
  jobId: resolvedJobId,
1999
3287
  resumed: createResult?.resumed ?? true,
2000
3288
  sourceQueryUrl: crawl.job.sourceQueryUrl,
@@ -2017,10 +3305,20 @@ program
2017
3305
  : null,
2018
3306
  lastOutcome: crawl.lastOutcome
2019
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
+ });
2020
3315
  if (options.out) {
2021
3316
  await writeJsonFile(options.out, payload);
2022
3317
  }
2023
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
+ }
2024
3322
  });
2025
3323
  program
2026
3324
  .command("salesnav:crawl:status")
@@ -2081,7 +3379,13 @@ program
2081
3379
  appliedFilters: item.appliedFilters,
2082
3380
  maxResultsPerSearch,
2083
3381
  numberOfProfiles,
2084
- 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
+ }
2085
3389
  });
2086
3390
  exported.push(result);
2087
3391
  }