salesprompter-cli 0.1.26 → 0.1.29

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,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { access, appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { access, appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
4
  import { createRequire } from "node:module";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
@@ -25,7 +25,7 @@ import { InstantlySyncProvider } from "./instantly.js";
25
25
  import { backfillLinkedInCompanies } from "./linkedin-companies.js";
26
26
  import { parseLinkedInCompanyPage } from "./linkedin-companies.js";
27
27
  import { crawlLinkedInProductCategory } from "./linkedin-products.js";
28
- import { claimValidatedSalesNavigatorSessionCookieForCli, createLinkedInSessionSupabaseClient } from "./linkedin-session.js";
28
+ import { claimValidatedSalesNavigatorSessionCookieForCli, createLinkedInSessionSupabaseClient, resolveConfiguredEnvValue } from "./linkedin-session.js";
29
29
  import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
30
30
  import { readJsonFile, splitCsv, writeJsonFile, writeTextFile } from "./io.js";
31
31
  import { buildSalesNavigatorCrawlPreview, createSalesNavigatorCrawlSeed, DEFAULT_SALES_NAVIGATOR_CRAWL_DIMENSIONS, buildSalesNavigatorPeopleSlice, deriveSalesNavigatorTitleQuerySeeds, expandSalesNavigatorCrawlAttempt, SalesNavigatorSliceTooBroadError } from "./sales-navigator.js";
@@ -33,7 +33,9 @@ import { buildSalesNavigatorHistoricalBackfillPlan, ensureSalesNavigatorPeopleCo
33
33
  const require = createRequire(import.meta.url);
34
34
  const { version: packageVersion } = require("../package.json");
35
35
  const program = new Command();
36
- const leadProvider = new AccountLeadProvider(new HeuristicCompanyProvider(), new HeuristicPeopleSearchProvider());
36
+ const companyProvider = new HeuristicCompanyProvider();
37
+ const peopleSearchProvider = new HeuristicPeopleSearchProvider();
38
+ const leadProvider = new AccountLeadProvider(companyProvider, peopleSearchProvider);
37
39
  const enrichmentProvider = new HeuristicEnrichmentProvider();
38
40
  const scoringProvider = new HeuristicScoringProvider();
39
41
  const syncProvider = new RoutedSyncProvider(new DryRunSyncProvider(), new InstantlySyncProvider());
@@ -42,6 +44,14 @@ const runtimeOutputOptions = {
42
44
  quiet: false
43
45
  };
44
46
  const nullableOptionalString = z.string().min(1).nullish().transform((value) => value ?? undefined);
47
+ const LinkedInCompanyBackfillClientIdStateSchema = z
48
+ .object({
49
+ clientId: z.number().int().positive(),
50
+ userId: z.string().optional(),
51
+ orgId: z.string().optional(),
52
+ updatedAt: z.string().datetime()
53
+ })
54
+ .passthrough();
45
55
  const WorkspaceLeadSchema = LeadSchema.extend({
46
56
  companySize: nullableOptionalString.optional(),
47
57
  country: nullableOptionalString.optional()
@@ -66,6 +76,9 @@ const LinkedInCompanyBackfillLaunchResponseSchema = z.object({
66
76
  webhookUrl: z.string().url(),
67
77
  inputUrl: z.string().url().nullable(),
68
78
  containerId: z.string().min(1).nullable(),
79
+ selectedSessionCookieSha256: z.string().min(1).nullable().optional(),
80
+ selectedSessionUserEmail: z.string().min(1).nullable().optional(),
81
+ selectedSessionUserHandle: z.string().min(1).nullable().optional(),
69
82
  candidates: z.array(z.object({
70
83
  companyId: z.number().int().positive(),
71
84
  companyUrl: z.string().url(),
@@ -78,7 +91,26 @@ const LinkedInCompanyBackfillStatusResponseSchema = z.object({
78
91
  containerId: z.string().min(1),
79
92
  running: z.boolean(),
80
93
  processed: z.boolean(),
81
- remaining: z.number().int().nonnegative()
94
+ remaining: z.number().int().nonnegative(),
95
+ failed: z.boolean().default(false),
96
+ failureCode: z.string().nullable().optional(),
97
+ failureMessage: z.string().nullable().optional()
98
+ });
99
+ const PhantombusterContainersSyncResponseSchema = z.object({
100
+ status: z.literal("ok"),
101
+ agentIds: z.array(z.string().min(1)),
102
+ agents: z.array(z.object({
103
+ agentId: z.string().min(1),
104
+ fetched: z.number().int().nonnegative(),
105
+ upserted: z.number().int().nonnegative(),
106
+ resultsSynced: z.number().int().nonnegative()
107
+ })),
108
+ fetched: z.number().int().nonnegative(),
109
+ upserted: z.number().int().nonnegative(),
110
+ resultsSynced: z.number().int().nonnegative(),
111
+ outputsStored: z.number().int().nonnegative(),
112
+ resultObjectsStored: z.number().int().nonnegative(),
113
+ resultRowsStored: z.number().int().nonnegative()
82
114
  });
83
115
  const CliEmailEnrichmentCompaniesResponseSchema = z.object({
84
116
  clientId: z.number().int().positive(),
@@ -248,8 +280,39 @@ const SalesNavigatorCrawlReportResponseSchema = z.object({
248
280
  status: z.literal("ok"),
249
281
  job: SalesNavigatorCrawlJobSummarySchema
250
282
  });
283
+ const cliPacks = [
284
+ {
285
+ slug: "contacts",
286
+ title: "Contacts",
287
+ summary: "Resolve profile URLs and work from pasted contact lists.",
288
+ commands: ["contacts:resolve-profiles", "contacts:resolve-emails"],
289
+ installStatus: "included"
290
+ },
291
+ {
292
+ slug: "research",
293
+ title: "Research",
294
+ summary: "Scrape markets and enrich companies before outreach.",
295
+ commands: ["market:scrape", "companies:enrich"],
296
+ installStatus: "included"
297
+ },
298
+ {
299
+ slug: "discovery",
300
+ title: "Discovery",
301
+ summary: "Find leads from product and market inputs.",
302
+ commands: ["leads:discover", "search:run", "search:status", "search:export", "search:count"],
303
+ installStatus: "included"
304
+ },
305
+ {
306
+ slug: "outreach",
307
+ title: "Outreach",
308
+ summary: "Prepare and sync qualified leads into downstream systems.",
309
+ commands: ["sync:outreach", "sync:crm"],
310
+ installStatus: "included"
311
+ }
312
+ ];
251
313
  const helpAliasByCommandName = new Map([
252
314
  ["contacts:find-linkedin-urls", "contacts:resolve-profiles"],
315
+ ["companies:find-linkedin-urls", "companies:resolve-linkedin-urls"],
253
316
  ["contacts:process-emails", "contacts:resolve-emails"],
254
317
  ["linkedin-companies:backfill", "companies:enrich"],
255
318
  ["linkedin-products:scrape", "market:scrape"],
@@ -260,11 +323,17 @@ const helpAliasByCommandName = new Map([
260
323
  ["salesnav:count", "search:count"]
261
324
  ]);
262
325
  const helpVisibleCommandNames = new Set([
326
+ "setup",
327
+ "doctor",
328
+ "packs:list",
329
+ "packs:add",
330
+ "upgrade",
263
331
  "auth:login",
264
332
  "wizard",
265
333
  "auth:whoami",
266
334
  "llm:ready",
267
335
  "contacts:find-linkedin-urls",
336
+ "companies:find-linkedin-urls",
268
337
  "contacts:process-emails",
269
338
  "auth:logout",
270
339
  "account:resolve",
@@ -304,6 +373,64 @@ function formatHelpArgumentTerm(argument) {
304
373
  }
305
374
  return argument.required ? `<${term}>` : `[${term}]`;
306
375
  }
376
+ function parsePositiveClientIdValue(rawValue) {
377
+ if (rawValue == null) {
378
+ throw new Error("clientId is required and must be a positive integer.");
379
+ }
380
+ const asString = String(rawValue).trim();
381
+ if (!asString) {
382
+ throw new Error("clientId is required and must be a positive integer.");
383
+ }
384
+ return z.coerce.number().int().positive().parse(asString);
385
+ }
386
+ function getLinkedInCompanyBackfillClientStatePath() {
387
+ return path.join(getSalesprompterConfigDir(), "linkedin-companies-backfill.json");
388
+ }
389
+ async function readLinkedInCompanyBackfillClientIdFromCache(session) {
390
+ const path = getLinkedInCompanyBackfillClientStatePath();
391
+ try {
392
+ const content = await readFile(path, "utf8");
393
+ const parsed = JSON.parse(content);
394
+ const state = LinkedInCompanyBackfillClientIdStateSchema.parse(parsed);
395
+ if (session?.user?.id != null && state.userId != null && state.userId !== session.user.id) {
396
+ return undefined;
397
+ }
398
+ if (session?.user?.orgId != null &&
399
+ state.orgId != null &&
400
+ String(state.orgId) !== String(session.user.orgId)) {
401
+ return undefined;
402
+ }
403
+ return state.clientId;
404
+ }
405
+ catch {
406
+ return undefined;
407
+ }
408
+ }
409
+ async function writeLinkedInCompanyBackfillClientIdToCache(clientId, session) {
410
+ const filePath = getLinkedInCompanyBackfillClientStatePath();
411
+ const state = {
412
+ clientId,
413
+ userId: session?.user?.id,
414
+ orgId: session?.user?.orgId,
415
+ updatedAt: new Date().toISOString()
416
+ };
417
+ await mkdir(path.dirname(filePath), { recursive: true });
418
+ await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
419
+ }
420
+ async function resolveLinkedInCompanyBackfillClientId(params) {
421
+ if (params.clientIdOption != null && String(params.clientIdOption).trim().length > 0) {
422
+ return parsePositiveClientIdValue(params.clientIdOption);
423
+ }
424
+ const envClientId = process.env.PIPEDREAM_CLIENT_ID?.trim() || process.env.SALESPROMPTER_CLIENT_ID?.trim();
425
+ if (envClientId) {
426
+ return parsePositiveClientIdValue(envClientId);
427
+ }
428
+ const cachedClientId = await readLinkedInCompanyBackfillClientIdFromCache(params.session);
429
+ if (cachedClientId != null) {
430
+ return cachedClientId;
431
+ }
432
+ throw new Error("Missing LinkedIn company backfill clientId. Pass --client-id, set PIPEDREAM_CLIENT_ID or SALESPROMPTER_CLIENT_ID, or run once with --client-id so the CLI can reuse it.");
433
+ }
307
434
  function applyGlobalOutputOptions(actionCommand) {
308
435
  const globalOptions = actionCommand.optsWithGlobals();
309
436
  runtimeOutputOptions.json = Boolean(globalOptions.json);
@@ -812,6 +939,13 @@ function splitLookupFullName(fullName) {
812
939
  function buildSyntheticLookupEmail(contactId) {
813
940
  return `linkedin-lookup+${contactId}@salesprompter.invalid`;
814
941
  }
942
+ function normalizeLinkedInLookupField(value) {
943
+ if (value == null) {
944
+ return undefined;
945
+ }
946
+ const normalized = normalizeLookupWhitespace(String(value));
947
+ return normalized || undefined;
948
+ }
815
949
  function looksLikeLookupCompanyRow(fullName, companyName) {
816
950
  const fullNameComparable = normalizeLooseMatchText(fullName);
817
951
  const companyComparable = normalizeLooseMatchText(companyName);
@@ -831,19 +965,32 @@ function parseLinkedInUrlLookupInput(content) {
831
965
  const parsed = z
832
966
  .array(z.object({
833
967
  clientId: z.union([z.string(), z.number()]).nullish(),
968
+ contactId: z.union([z.string(), z.number()]).nullish(),
969
+ companyId: z.union([z.string(), z.number()]).nullish(),
834
970
  fullName: z.string().nullish(),
835
971
  companyName: z.string().nullish(),
836
972
  email: z.string().nullish(),
837
- jobTitle: z.string().nullish()
973
+ contact_email: z.string().nullish(),
974
+ jobTitle: z.string().nullish(),
975
+ jobtitle: z.string().nullish(),
976
+ title: z.string().nullish(),
977
+ linkedin_company_url: z.string().nullish(),
978
+ linkedinCompanyUrl: z.string().nullish(),
979
+ deep_dive_recommended_role: z.string().nullish(),
980
+ deepDiveRecommendedRole: z.string().nullish()
838
981
  }))
839
982
  .parse(JSON.parse(trimmed));
840
983
  return parsed
841
984
  .map((row) => ({
842
985
  clientId: row.clientId == null ? null : String(row.clientId).trim() || null,
986
+ contactId: row.contactId == null ? undefined : String(row.contactId).trim() || undefined,
987
+ companyId: row.companyId == null ? undefined : String(row.companyId).trim() || undefined,
843
988
  fullName: row.fullName?.trim() ?? "",
844
989
  companyName: row.companyName?.trim() ?? "",
845
- email: row.email?.trim() || undefined,
846
- jobTitle: row.jobTitle?.trim() || undefined
990
+ email: row.email?.trim() || row.contact_email?.trim() || undefined,
991
+ jobTitle: row.jobTitle?.trim() || row.jobtitle?.trim() || row.title?.trim() || undefined,
992
+ linkedinCompanyUrl: row.linkedin_company_url?.trim() || row.linkedinCompanyUrl?.trim() || undefined,
993
+ deepDiveRecommendedRole: row.deep_dive_recommended_role?.trim() || row.deepDiveRecommendedRole?.trim() || undefined
847
994
  }))
848
995
  .filter((row) => row.fullName.length > 0 || row.companyName.length > 0);
849
996
  }
@@ -871,23 +1018,90 @@ function parseLinkedInUrlLookupInput(content) {
871
1018
  ? headerValues.findIndex((value) => ["companyname", "company_name"].includes(value))
872
1019
  : 2;
873
1020
  const emailIndex = hasHeader ? headerValues.findIndex((value) => value === "email") : -1;
1021
+ const contactEmailIndex = hasHeader ? headerValues.findIndex((value) => value === "contact_email") : -1;
874
1022
  const jobTitleIndex = hasHeader
875
1023
  ? headerValues.findIndex((value) => ["jobtitle", "job_title", "title"].includes(value))
876
1024
  : -1;
1025
+ const contactIdIndex = hasHeader
1026
+ ? headerValues.findIndex((value) => ["contactid", "contact_id", "hubspot_contact_id"].includes(value))
1027
+ : -1;
1028
+ const companyIdIndex = hasHeader
1029
+ ? headerValues.findIndex((value) => ["companyid", "company_id", "hubspot_company_id"].includes(value))
1030
+ : -1;
1031
+ const linkedinCompanyUrlIndex = hasHeader
1032
+ ? headerValues.findIndex((value) => ["linkedin_company_url", "linkedincompanyurl"].includes(value))
1033
+ : -1;
1034
+ const deepDiveRecommendedRoleIndex = hasHeader
1035
+ ? headerValues.findIndex((value) => ["deep_dive_recommended_role", "deepdiverecommendedrole"].includes(value))
1036
+ : -1;
877
1037
  return dataLines
878
1038
  .map((line) => splitLooseDelimitedLine(line, delimiter).map((value) => value.trim()))
879
1039
  .map((columns) => ({
880
1040
  clientId: clientIdIndex >= 0 ? columns[clientIdIndex] || null : null,
1041
+ contactId: contactIdIndex >= 0 ? columns[contactIdIndex] || undefined : undefined,
1042
+ companyId: companyIdIndex >= 0 ? columns[companyIdIndex] || undefined : undefined,
881
1043
  fullName: fullNameIndex >= 0 ? columns[fullNameIndex] || "" : "",
882
1044
  companyName: companyNameIndex >= 0 ? columns[companyNameIndex] || "" : "",
883
- email: emailIndex >= 0 ? columns[emailIndex] || undefined : undefined,
884
- jobTitle: jobTitleIndex >= 0 ? columns[jobTitleIndex] || undefined : undefined
1045
+ email: (emailIndex >= 0 ? columns[emailIndex] || undefined : undefined) ??
1046
+ (contactEmailIndex >= 0 ? columns[contactEmailIndex] || undefined : undefined),
1047
+ jobTitle: jobTitleIndex >= 0 ? columns[jobTitleIndex] || undefined : undefined,
1048
+ linkedinCompanyUrl: linkedinCompanyUrlIndex >= 0 ? columns[linkedinCompanyUrlIndex] || undefined : undefined,
1049
+ deepDiveRecommendedRole: deepDiveRecommendedRoleIndex >= 0 ? columns[deepDiveRecommendedRoleIndex] || undefined : undefined
885
1050
  }))
886
1051
  .filter((row) => row.fullName.length > 0 || row.companyName.length > 0);
887
1052
  }
1053
+ function parseLinkedInCompanyLookupInput(content) {
1054
+ const trimmed = content.trim();
1055
+ if (!trimmed) {
1056
+ return [];
1057
+ }
1058
+ if (trimmed.startsWith("[")) {
1059
+ const parsed = z
1060
+ .array(z.object({
1061
+ clientId: z.union([z.string(), z.number()]).nullish(),
1062
+ companyName: z.string().nullish(),
1063
+ name: z.string().nullish()
1064
+ }))
1065
+ .parse(JSON.parse(trimmed));
1066
+ return parsed
1067
+ .map((row) => ({
1068
+ clientId: row.clientId == null ? null : String(row.clientId).trim() || null,
1069
+ companyName: normalizeLookupWhitespace(row.companyName?.trim() || row.name?.trim() || "")
1070
+ }))
1071
+ .filter((row) => row.companyName.length > 0);
1072
+ }
1073
+ const lines = trimmed
1074
+ .split(/\r?\n/)
1075
+ .map((line) => line.trim())
1076
+ .filter((line) => line.length > 0);
1077
+ if (lines.length === 0) {
1078
+ return [];
1079
+ }
1080
+ const delimiter = detectLooseDelimiter(lines[0] ?? "");
1081
+ const headerValues = splitLooseDelimitedLine(lines[0] ?? "", delimiter).map((value) => value.trim().toLowerCase());
1082
+ const hasHeader = headerValues.includes("companyname") ||
1083
+ headerValues.includes("company_name") ||
1084
+ headerValues.includes("name");
1085
+ if (hasHeader) {
1086
+ const companyNameIndex = headerValues.findIndex((value) => ["companyname", "company_name", "name"].includes(value));
1087
+ const clientIdIndex = headerValues.findIndex((value) => ["clientid", "client_id"].includes(value));
1088
+ return lines
1089
+ .slice(1)
1090
+ .map((line) => splitLooseDelimitedLine(line, delimiter).map((value) => value.trim()))
1091
+ .map((columns) => ({
1092
+ clientId: clientIdIndex >= 0 ? columns[clientIdIndex] || null : null,
1093
+ companyName: normalizeLookupWhitespace(companyNameIndex >= 0 ? columns[companyNameIndex] || "" : "")
1094
+ }))
1095
+ .filter((row) => row.companyName.length > 0);
1096
+ }
1097
+ return lines.map((line) => ({
1098
+ clientId: null,
1099
+ companyName: normalizeLookupWhitespace(line)
1100
+ }));
1101
+ }
888
1102
  function toLinkedInUrlLookupContacts(rows, cleanedCompanyMap = new Map()) {
889
1103
  return rows.flatMap((row, index) => {
890
- const contactId = String(index + 1);
1104
+ const contactId = normalizeLinkedInLookupField(row.contactId) ?? String(index + 1);
891
1105
  const syntheticEmail = row.email?.trim() || buildSyntheticLookupEmail(contactId);
892
1106
  const rawCompanyName = normalizeLookupWhitespace(row.companyName);
893
1107
  const cleanedCompanyName = normalizeLookupCompanyForSearch(cleanedCompanyMap.get(normalizeLookupCompanyForCleaning(rawCompanyName)) ?? rawCompanyName);
@@ -901,7 +1115,10 @@ function toLinkedInUrlLookupContacts(rows, cleanedCompanyMap = new Map()) {
901
1115
  companyName: cleanedCompanyName,
902
1116
  companyNameOriginal: rawCompanyName || undefined,
903
1117
  email: syntheticEmail,
904
- jobTitle: row.jobTitle
1118
+ jobTitle: row.jobTitle,
1119
+ companyId: normalizeLinkedInLookupField(row.companyId),
1120
+ linkedinCompanyUrl: row.linkedinCompanyUrl?.trim() || undefined,
1121
+ deepDiveRecommendedRole: row.deepDiveRecommendedRole?.trim() || undefined
905
1122
  }
906
1123
  ];
907
1124
  }
@@ -916,7 +1133,10 @@ function toLinkedInUrlLookupContacts(rows, cleanedCompanyMap = new Map()) {
916
1133
  companyName: cleanedCompanyName,
917
1134
  companyNameOriginal: rawCompanyName || undefined,
918
1135
  email: syntheticEmail,
919
- jobTitle: row.jobTitle
1136
+ jobTitle: row.jobTitle,
1137
+ companyId: normalizeLinkedInLookupField(row.companyId),
1138
+ linkedinCompanyUrl: row.linkedinCompanyUrl?.trim() || undefined,
1139
+ deepDiveRecommendedRole: row.deepDiveRecommendedRole?.trim() || undefined
920
1140
  }
921
1141
  ];
922
1142
  const rawDiffers = rawSplit.firstName !== cleanedSplit.firstName ||
@@ -930,6 +1150,9 @@ function toLinkedInUrlLookupContacts(rows, cleanedCompanyMap = new Map()) {
930
1150
  companyNameOriginal: rawCompanyName || undefined,
931
1151
  email: syntheticEmail,
932
1152
  jobTitle: row.jobTitle,
1153
+ companyId: normalizeLinkedInLookupField(row.companyId),
1154
+ linkedinCompanyUrl: row.linkedinCompanyUrl?.trim() || undefined,
1155
+ deepDiveRecommendedRole: row.deepDiveRecommendedRole?.trim() || undefined,
933
1156
  isVariation: true
934
1157
  });
935
1158
  }
@@ -937,25 +1160,147 @@ function toLinkedInUrlLookupContacts(rows, cleanedCompanyMap = new Map()) {
937
1160
  });
938
1161
  }
939
1162
  function readPipedreamLinkedInEnrichmentConfig() {
940
- const endpointUrl = process.env.SALESPROMPTER_LINKEDIN_ENRICHMENT_ENDPOINT_URL?.trim() ||
941
- (process.env.PIPEDREAM_ENDPOINT_ID?.trim()
942
- ? `https://${process.env.PIPEDREAM_ENDPOINT_ID.trim()}.m.pipedream.net`
1163
+ const endpointUrl = resolveConfiguredEnvValue(process.env, "SALESPROMPTER_LINKEDIN_ENRICHMENT_ENDPOINT_URL") ||
1164
+ (resolveConfiguredEnvValue(process.env, "PIPEDREAM_ENDPOINT_ID")
1165
+ ? `https://${resolveConfiguredEnvValue(process.env, "PIPEDREAM_ENDPOINT_ID")?.trim()}.m.pipedream.net`
943
1166
  : "");
944
1167
  if (!endpointUrl) {
945
1168
  throw new Error("Missing LinkedIn enrichment endpoint. Set SALESPROMPTER_LINKEDIN_ENRICHMENT_ENDPOINT_URL or PIPEDREAM_ENDPOINT_ID.");
946
1169
  }
947
1170
  return {
948
1171
  endpointUrl,
949
- secret: process.env.PIPEDREAM_SECRET_KEY?.trim() || "",
950
- clientId: process.env.PIPEDREAM_CLIENT_ID?.trim() || "",
951
- projectId: process.env.PIPEDREAM_PROJECT_ID?.trim() || "",
952
- projectEnvironment: process.env.PIPEDREAM_PROJECT_ENVIRONMENT?.trim() || ""
1172
+ secret: resolveConfiguredEnvValue(process.env, "PIPEDREAM_SECRET_KEY") || "",
1173
+ clientId: resolveConfiguredEnvValue(process.env, "PIPEDREAM_CLIENT_ID") || "",
1174
+ projectId: resolveConfiguredEnvValue(process.env, "PIPEDREAM_PROJECT_ID") || "",
1175
+ projectEnvironment: resolveConfiguredEnvValue(process.env, "PIPEDREAM_PROJECT_ENVIRONMENT") || ""
953
1176
  };
954
1177
  }
1178
+ function isSyntheticLinkedInLookupEmail(value) {
1179
+ const normalized = normalizeLookupWhitespace(value).toLowerCase();
1180
+ return normalized.endsWith("@salesprompter.invalid");
1181
+ }
955
1182
  function deriveCsrfTokenFromCookie(cookie) {
956
1183
  const match = cookie.match(/JSESSIONID="?([^";]+)"?/i);
957
1184
  return match?.[1]?.trim() || "";
958
1185
  }
1186
+ function normalizeLinkedInDirectLookupCookieHeader(cookie) {
1187
+ const trimmed = normalizeLookupWhitespace(cookie);
1188
+ if (!trimmed) {
1189
+ return "";
1190
+ }
1191
+ if (trimmed.includes("=") || trimmed.includes(";")) {
1192
+ return trimmed;
1193
+ }
1194
+ return `li_at=${trimmed}`;
1195
+ }
1196
+ function parseLocalLinkedInExtensionTokenLog(content) {
1197
+ const matches = [
1198
+ ...content.matchAll(/\{"csrfToken":"([^"]+)","extractedFrom":"sales-api\/salesApiLeadSearch"[\s\S]*?"linkedInIdentity":"([^"]+)"[\s\S]*?"sessionCookie":"([\s\S]*?)","syncStatus":"(success|captured)"[\s\S]*?"userAgent":"([^"]+)"\}/g)
1199
+ ];
1200
+ const last = matches.at(-1);
1201
+ if (!last) {
1202
+ return null;
1203
+ }
1204
+ const csrfToken = normalizeLookupWhitespace(last[1]);
1205
+ const linkedInIdentity = normalizeLookupWhitespace(last[2]);
1206
+ const sessionCookie = normalizeLookupWhitespace(last[3]?.replace(/\\"/g, "\"").replace(/\\\\/g, "\\"));
1207
+ const userAgent = normalizeLookupWhitespace(last[5]);
1208
+ if (!csrfToken || !linkedInIdentity || !sessionCookie || !userAgent) {
1209
+ return null;
1210
+ }
1211
+ return {
1212
+ csrfToken,
1213
+ linkedInIdentity,
1214
+ sessionCookie,
1215
+ userAgent
1216
+ };
1217
+ }
1218
+ async function readLocalLinkedInExtensionTokenLog(filePath) {
1219
+ try {
1220
+ const content = await readFile(filePath, "latin1");
1221
+ return parseLocalLinkedInExtensionTokenLog(content);
1222
+ }
1223
+ catch {
1224
+ return null;
1225
+ }
1226
+ }
1227
+ async function listChromeExtensionTokenLogCandidates() {
1228
+ const overrideFile = normalizeLookupWhitespace(process.env.SALESPROMPTER_LINKEDIN_EXTENSION_TOKENS_LOG_PATH);
1229
+ if (overrideFile) {
1230
+ return [overrideFile];
1231
+ }
1232
+ const overrideDir = normalizeLookupWhitespace(process.env.SALESPROMPTER_LINKEDIN_EXTENSION_TOKENS_DIR);
1233
+ if (overrideDir) {
1234
+ try {
1235
+ const files = await readdir(overrideDir);
1236
+ return files
1237
+ .filter((file) => file.endsWith(".log") || file.endsWith(".ldb"))
1238
+ .map((file) => path.join(overrideDir, file))
1239
+ .sort()
1240
+ .reverse();
1241
+ }
1242
+ catch {
1243
+ return [];
1244
+ }
1245
+ }
1246
+ const chromeRootCandidates = [
1247
+ path.join(os.homedir(), "Library", "Application Support", "Google", "Chrome"),
1248
+ path.join(os.homedir(), "Library", "Application Support", "Chromium")
1249
+ ];
1250
+ const paths = [];
1251
+ for (const chromeRoot of chromeRootCandidates) {
1252
+ let profileDirs = [];
1253
+ try {
1254
+ profileDirs = await readdir(chromeRoot);
1255
+ }
1256
+ catch {
1257
+ continue;
1258
+ }
1259
+ for (const profileDir of profileDirs) {
1260
+ const extensionSettingsRoot = path.join(chromeRoot, profileDir, "Local Extension Settings");
1261
+ let extensionIds = [];
1262
+ try {
1263
+ extensionIds = await readdir(extensionSettingsRoot);
1264
+ }
1265
+ catch {
1266
+ continue;
1267
+ }
1268
+ for (const extensionId of extensionIds) {
1269
+ const extensionDir = path.join(extensionSettingsRoot, extensionId);
1270
+ let files = [];
1271
+ try {
1272
+ files = await readdir(extensionDir);
1273
+ }
1274
+ catch {
1275
+ continue;
1276
+ }
1277
+ for (const file of files) {
1278
+ if (!file.endsWith(".log")) {
1279
+ continue;
1280
+ }
1281
+ paths.push(path.join(extensionDir, file));
1282
+ }
1283
+ }
1284
+ }
1285
+ }
1286
+ return paths.sort().reverse();
1287
+ }
1288
+ async function readLocalLinkedInExtensionDirectLookupConfig() {
1289
+ const candidates = await listChromeExtensionTokenLogCandidates();
1290
+ for (const candidate of candidates) {
1291
+ const snapshot = await readLocalLinkedInExtensionTokenLog(candidate);
1292
+ if (!snapshot) {
1293
+ continue;
1294
+ }
1295
+ return {
1296
+ csrfToken: snapshot.csrfToken,
1297
+ identity: snapshot.linkedInIdentity,
1298
+ cookie: normalizeLinkedInDirectLookupCookieHeader(snapshot.sessionCookie),
1299
+ userAgent: snapshot.userAgent
1300
+ };
1301
+ }
1302
+ return null;
1303
+ }
959
1304
  function readLinkedInDirectLookupEnvConfig() {
960
1305
  const cookie = process.env.SALESPROMPTER_LINKEDIN_SALES_NAV_COOKIE?.trim() ||
961
1306
  process.env.LINKEDIN_SALES_NAV_COOKIE?.trim() ||
@@ -972,7 +1317,7 @@ function readLinkedInDirectLookupEnvConfig() {
972
1317
  return {
973
1318
  csrfToken,
974
1319
  identity,
975
- cookie,
1320
+ cookie: normalizeLinkedInDirectLookupCookieHeader(cookie),
976
1321
  userAgent: process.env.SALESPROMPTER_LINKEDIN_USER_AGENT?.trim() ||
977
1322
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
978
1323
  };
@@ -1022,7 +1367,7 @@ async function readStoredLinkedInDirectLookupConfig() {
1022
1367
  return {
1023
1368
  csrfToken,
1024
1369
  identity,
1025
- cookie: claimed.sessionCookie,
1370
+ cookie: normalizeLinkedInDirectLookupCookieHeader(claimed.sessionCookie),
1026
1371
  userAgent
1027
1372
  };
1028
1373
  }
@@ -1036,6 +1381,11 @@ async function readLinkedInDirectLookupConfig() {
1036
1381
  cachedLinkedInDirectLookupConfig = envConfig;
1037
1382
  return envConfig;
1038
1383
  }
1384
+ const localExtensionConfig = await readLocalLinkedInExtensionDirectLookupConfig();
1385
+ if (localExtensionConfig) {
1386
+ cachedLinkedInDirectLookupConfig = localExtensionConfig;
1387
+ return localExtensionConfig;
1388
+ }
1039
1389
  const storedConfig = await readStoredLinkedInDirectLookupConfig();
1040
1390
  if (storedConfig) {
1041
1391
  cachedLinkedInDirectLookupConfig = storedConfig;
@@ -1052,51 +1402,310 @@ function buildLinkedInSalesApiUrl(params) {
1052
1402
  const encodedFirstName = encodeURIComponent(params.firstName);
1053
1403
  const encodedLastName = encodeURIComponent(params.lastName);
1054
1404
  const encodedCompanyName = encodeURIComponent(params.companyName);
1405
+ const encodedKeywords = encodeURIComponent(params.keywordsText?.trim() || params.companyName);
1055
1406
  const filters = params.searchMode === "current_company"
1056
1407
  ? `(type:FIRST_NAME,values:List((text:${encodedFirstName},selectionType:INCLUDED))),(type:LAST_NAME,values:List((text:${encodedLastName},selectionType:INCLUDED))),(type:CURRENT_COMPANY,values:List((text:${encodedCompanyName},selectionType:INCLUDED)))`
1057
1408
  : `(type:FIRST_NAME,values:List((text:${encodedFirstName},selectionType:INCLUDED))),(type:LAST_NAME,values:List((text:${encodedLastName},selectionType:INCLUDED)))`;
1058
- const keywordsSegment = params.searchMode === "keywords" ? `,keywords:${encodedCompanyName}` : "";
1409
+ const keywordsSegment = params.searchMode === "current_company" ? "" : `,keywords:${encodedKeywords}`;
1059
1410
  return `${baseUrl.replace(/\/+$/, "")}/sales-api/salesApiLeadSearch?q=searchQuery&query=(recentSearchParam:(id:${Date.now()},doLogHistory:true),filters:List(${filters})${keywordsSegment})&start=0&count=25&trackingParam=(sessionId:${generateLinkedInSessionId()})&decorationId=com.linkedin.sales.deco.desktop.searchv2.LeadSearchResult-14`;
1060
1411
  }
1412
+ function extractLookupTitleKeywords(value) {
1413
+ const shortAllowlist = new Set(["hr", "it", "cfo"]);
1414
+ return normalizeLooseMatchText(value)
1415
+ .split(/\s+/)
1416
+ .filter((token) => token.length >= 4 || shortAllowlist.has(token))
1417
+ .filter((token) => ![
1418
+ "head",
1419
+ "senior",
1420
+ "consultant",
1421
+ "manager",
1422
+ "specialist",
1423
+ "lead",
1424
+ "global",
1425
+ "team",
1426
+ "group"
1427
+ ].includes(token))
1428
+ .slice(0, 4);
1429
+ }
1430
+ function buildDeepDiveRoleSearchKeywords(role) {
1431
+ const normalized = normalizeLooseMatchText(role);
1432
+ switch (normalized) {
1433
+ case "budgetholder":
1434
+ return ["finance", "procurement", "purchasing", "accounting", "controlling", "cfo"];
1435
+ case "decisionmaker":
1436
+ return ["director", "head", "vp", "chief", "leiter", "lead"];
1437
+ case "champion":
1438
+ return ["hr", "workplace", "operations", "it", "people", "office"];
1439
+ case "executivesponsor":
1440
+ return ["executive", "board", "chief", "managing", "director", "ceo"];
1441
+ case "influencer":
1442
+ return ["specialist", "manager", "consultant", "project", "workplace", "hr"];
1443
+ case "legalandcompliance":
1444
+ return ["legal", "compliance", "datenschutz", "counsel"];
1445
+ case "blocker":
1446
+ return ["procurement", "legal", "compliance", "security"];
1447
+ case "enduser":
1448
+ return ["workplace", "office", "operations", "assistant", "admin"];
1449
+ default:
1450
+ return [];
1451
+ }
1452
+ }
1061
1453
  function buildLinkedInAccountSearchApiUrl(companyName) {
1062
1454
  const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
1063
1455
  "https://www.linkedin.com";
1064
1456
  const encodedCompanyName = encodeURIComponent(companyName);
1065
1457
  return `${baseUrl.replace(/\/+$/, "")}/sales-api/salesApiAccountSearch?q=searchQuery&query=(recentSearchParam:(id:${Date.now()},doLogHistory:true),spellCorrectionEnabled:true,keywords:${encodedCompanyName})&start=0&count=10&trackingParam=(sessionId:${generateLinkedInSessionId()})&decorationId=com.linkedin.sales.deco.desktop.searchv2.AccountSearchResult-14`;
1066
1458
  }
1067
- function buildLinkedInLookupSearchVariants(contact) {
1459
+ async function buildLinkedInLookupSearchVariants(contact, timeoutMs, resolvedCompanyAliases = []) {
1068
1460
  const variants = [];
1069
1461
  const seen = new Set();
1070
- const companyCandidates = [
1071
- normalizeLookupWhitespace(contact.companyName),
1072
- normalizeLookupWhitespace(contact.companyNameOriginal)
1073
- ].filter(Boolean);
1074
- for (const companyName of companyCandidates) {
1075
- for (const searchMode of ["current_company", "keywords"]) {
1076
- const key = [
1077
- contact.firstName.trim().toLowerCase(),
1078
- contact.lastName.trim().toLowerCase(),
1079
- companyName.toLowerCase(),
1080
- searchMode
1081
- ].join("|");
1082
- if (seen.has(key)) {
1083
- continue;
1084
- }
1085
- seen.add(key);
1086
- variants.push({
1087
- firstName: contact.firstName,
1088
- lastName: contact.lastName,
1089
- companyName,
1090
- searchMode
1091
- });
1462
+ const companyCandidateScores = new Map();
1463
+ const addCompanyCandidate = (value, score) => {
1464
+ const normalized = normalizeLookupWhitespace(value);
1465
+ if (!normalized) {
1466
+ return;
1467
+ }
1468
+ companyCandidateScores.set(normalized, Math.max(score, companyCandidateScores.get(normalized) ?? 0));
1469
+ };
1470
+ addCompanyCandidate(contact.companyName, 80);
1471
+ addCompanyCandidate(contact.companyNameOriginal, 70);
1472
+ const linkedInHandle = normalizeLinkedInCompanyHandle(contact.linkedinCompanyUrl ?? "");
1473
+ if (linkedInHandle && !/^\d+$/.test(linkedInHandle)) {
1474
+ addCompanyCandidate(linkedInHandle.replace(/[-_]+/g, " "), 95);
1475
+ }
1476
+ for (const alias of resolvedCompanyAliases) {
1477
+ addCompanyCandidate(alias, 110);
1478
+ }
1479
+ const emailDomain = (() => {
1480
+ const email = normalizeLookupWhitespace(contact.email);
1481
+ if (!email || isSyntheticLinkedInLookupEmail(email)) {
1482
+ return "";
1483
+ }
1484
+ const at = email.lastIndexOf("@");
1485
+ return at >= 0 ? email.slice(at + 1) : "";
1486
+ })();
1487
+ if (emailDomain) {
1488
+ const host = emailDomain.replace(/^www\./i, "").split(".")[0] ?? "";
1489
+ if (host) {
1490
+ addCompanyCandidate(host.replace(/[-_]+/g, " "), 100);
1092
1491
  }
1093
1492
  }
1094
- return variants;
1493
+ if (contact.jobTitle && contact.deepDiveRecommendedRole) {
1494
+ const primaryWord = normalizeLookupWhitespace(contact.companyNameOriginal ?? contact.companyName)
1495
+ .split(/\s+/)
1496
+ .filter((part) => part.length >= 4)
1497
+ .slice(-1)[0];
1498
+ if (primaryWord) {
1499
+ addCompanyCandidate(primaryWord, 45);
1500
+ }
1501
+ }
1502
+ const companyHints = await buildLinkedInProfileCompanyHints(contact, timeoutMs);
1503
+ for (const phrase of companyHints.phrases) {
1504
+ const tokenCount = normalizeLooseMatchText(phrase).split(/\s+/).filter(Boolean).length;
1505
+ if (tokenCount >= 1 && tokenCount <= 4) {
1506
+ addCompanyCandidate(phrase, tokenCount <= 2 ? 75 : 60);
1507
+ }
1508
+ }
1509
+ for (const keyword of companyHints.keywords.slice(0, 5)) {
1510
+ addCompanyCandidate(keyword, keyword.includes(".") ? 90 : 55);
1511
+ }
1512
+ const titleKeywords = Array.from(new Set([
1513
+ ...extractLookupTitleKeywords(contact.jobTitle),
1514
+ ...buildDeepDiveRoleSearchKeywords(contact.deepDiveRecommendedRole)
1515
+ ])).slice(0, 6);
1516
+ const rankedCompanyCandidates = Array.from(companyCandidateScores.entries())
1517
+ .sort((left, right) => right[1] - left[1] || left[0].length - right[0].length)
1518
+ .slice(0, 6);
1519
+ const emailHostCandidate = (() => {
1520
+ if (!emailDomain) {
1521
+ return "";
1522
+ }
1523
+ return normalizeLookupWhitespace(emailDomain.replace(/^www\./i, "").split(".")[0] ?? "").replace(/[-_]+/g, " ");
1524
+ })();
1525
+ const cleanCompanyCandidate = normalizeLookupWhitespace(contact.companyName) ||
1526
+ normalizeLookupWhitespace(contact.companyNameOriginal) ||
1527
+ "";
1528
+ const linkedInHandleCandidate = linkedInHandle && !/^\d+$/.test(linkedInHandle)
1529
+ ? normalizeLookupWhitespace(linkedInHandle.replace(/[-_]+/g, " "))
1530
+ : "";
1531
+ const pushVariant = (companyName, searchMode) => {
1532
+ const normalizedCompany = normalizeLookupWhitespace(companyName);
1533
+ if (!normalizedCompany) {
1534
+ return;
1535
+ }
1536
+ const keywordsText = searchMode === "keywords_title" && titleKeywords.length > 0
1537
+ ? `${normalizedCompany} ${titleKeywords.join(" ")}`
1538
+ : undefined;
1539
+ if (searchMode === "keywords_title" && !keywordsText) {
1540
+ return;
1541
+ }
1542
+ const key = [
1543
+ contact.firstName.trim().toLowerCase(),
1544
+ contact.lastName.trim().toLowerCase(),
1545
+ normalizedCompany.toLowerCase(),
1546
+ searchMode,
1547
+ keywordsText?.toLowerCase() ?? ""
1548
+ ].join("|");
1549
+ if (seen.has(key)) {
1550
+ return;
1551
+ }
1552
+ seen.add(key);
1553
+ variants.push({
1554
+ firstName: contact.firstName,
1555
+ lastName: contact.lastName,
1556
+ companyName: normalizedCompany,
1557
+ searchMode,
1558
+ keywordsText
1559
+ });
1560
+ };
1561
+ const rankedCompanyNames = rankedCompanyCandidates.map(([companyName]) => companyName);
1562
+ const currentCompanyStageCandidates = [
1563
+ emailHostCandidate,
1564
+ linkedInHandleCandidate,
1565
+ ...resolvedCompanyAliases,
1566
+ ...rankedCompanyNames.filter((companyName) => (companyCandidateScores.get(companyName) ?? 0) >= 90)
1567
+ ];
1568
+ const keywordStageCandidates = [
1569
+ cleanCompanyCandidate,
1570
+ ...rankedCompanyNames
1571
+ ];
1572
+ const keywordTitleStageCandidates = [
1573
+ cleanCompanyCandidate,
1574
+ ...rankedCompanyNames
1575
+ ];
1576
+ const fallbackCurrentCompanyCandidates = [
1577
+ cleanCompanyCandidate,
1578
+ normalizeLookupWhitespace(contact.companyNameOriginal),
1579
+ ...rankedCompanyNames
1580
+ ];
1581
+ for (const companyName of currentCompanyStageCandidates) {
1582
+ pushVariant(companyName, "current_company");
1583
+ }
1584
+ for (const companyName of keywordStageCandidates) {
1585
+ pushVariant(companyName, "keywords");
1586
+ }
1587
+ for (const companyName of keywordTitleStageCandidates) {
1588
+ pushVariant(companyName, "keywords_title");
1589
+ }
1590
+ for (const companyName of fallbackCurrentCompanyCandidates) {
1591
+ pushVariant(companyName, "current_company");
1592
+ }
1593
+ for (const [companyName] of rankedCompanyCandidates) {
1594
+ pushVariant(companyName, "current_company");
1595
+ pushVariant(companyName, "keywords");
1596
+ pushVariant(companyName, "keywords_title");
1597
+ }
1598
+ return variants.slice(0, 12);
1599
+ }
1600
+ function normalizeSalesNavLeadUrl(value) {
1601
+ const trimmed = String(value ?? "").trim();
1602
+ if (!trimmed) {
1603
+ return null;
1604
+ }
1605
+ const directMatch = trimmed.match(/https:\/\/www\.linkedin\.com\/sales\/lead\/[^/?#]+/i);
1606
+ if (directMatch) {
1607
+ return directMatch[0] ?? null;
1608
+ }
1609
+ const disguisedLeadIdMatch = trimmed.match(/https:\/\/www\.linkedin\.com\/in\/(ACw[A-Za-z0-9_-]+)/i);
1610
+ if (disguisedLeadIdMatch?.[1]) {
1611
+ return `https://www.linkedin.com/sales/lead/${disguisedLeadIdMatch[1]}`;
1612
+ }
1613
+ return null;
1614
+ }
1615
+ function normalizePublicLinkedInProfileUrl(value) {
1616
+ const trimmed = String(value ?? "").trim();
1617
+ if (!trimmed) {
1618
+ return null;
1619
+ }
1620
+ let parsed;
1621
+ try {
1622
+ parsed = new URL(trimmed);
1623
+ }
1624
+ catch {
1625
+ return null;
1626
+ }
1627
+ if (!/(^|\.)linkedin\.com$/i.test(parsed.hostname)) {
1628
+ return null;
1629
+ }
1630
+ const pathMatch = parsed.pathname.match(/^\/in\/([^/?#]+)\/?/i);
1631
+ if (!pathMatch?.[1]) {
1632
+ return null;
1633
+ }
1634
+ const candidate = `https://www.linkedin.com/in/${pathMatch[1]}`;
1635
+ return normalizeSalesNavLeadUrl(candidate) ? null : candidate;
1095
1636
  }
1096
1637
  function extractLinkedInProfileUrlFromSalesApiElement(element) {
1097
- const entityUrn = typeof element?.entityUrn === "string" ? element.entityUrn : "";
1638
+ if (!element) {
1639
+ return null;
1640
+ }
1641
+ const explicitCandidates = [
1642
+ typeof element.linkedinProfileUrl === "string" ? element.linkedinProfileUrl : null,
1643
+ typeof element.profileUrl === "string" ? element.profileUrl : null,
1644
+ typeof element.url === "string" ? element.url : null
1645
+ ].filter(Boolean);
1646
+ for (const candidate of explicitCandidates) {
1647
+ const normalized = normalizePublicLinkedInProfileUrl(candidate);
1648
+ if (normalized) {
1649
+ return normalized;
1650
+ }
1651
+ }
1652
+ for (const value of collectNestedStrings(element)) {
1653
+ const normalized = normalizePublicLinkedInProfileUrl(value);
1654
+ if (normalized) {
1655
+ return normalized;
1656
+ }
1657
+ }
1658
+ return null;
1659
+ }
1660
+ function extractLinkedInSalesNavLeadUrlFromSalesApiElement(element) {
1661
+ if (!element) {
1662
+ return null;
1663
+ }
1664
+ const explicitCandidates = [
1665
+ typeof element.salesNavProfileUrl === "string" ? element.salesNavProfileUrl : null,
1666
+ typeof element.sales_nav_profile_url === "string" ? element.sales_nav_profile_url : null,
1667
+ typeof element.url === "string" ? element.url : null
1668
+ ].filter(Boolean);
1669
+ for (const candidate of explicitCandidates) {
1670
+ const normalized = normalizeSalesNavLeadUrl(candidate);
1671
+ if (normalized) {
1672
+ return normalized;
1673
+ }
1674
+ }
1675
+ for (const value of collectNestedStrings(element)) {
1676
+ const normalized = normalizeSalesNavLeadUrl(value);
1677
+ if (normalized) {
1678
+ return normalized;
1679
+ }
1680
+ }
1681
+ const entityUrn = typeof element.entityUrn === "string" ? element.entityUrn : "";
1098
1682
  const salesIdMatch = entityUrn.match(/\(([^,]+),/);
1099
- return salesIdMatch ? `https://www.linkedin.com/in/${salesIdMatch[1]}` : null;
1683
+ return salesIdMatch?.[1] ? `https://www.linkedin.com/sales/lead/${salesIdMatch[1]}` : null;
1684
+ }
1685
+ function extractLinkedInSalesNavCompanyUrlFromSalesApiElement(element) {
1686
+ if (!element) {
1687
+ return null;
1688
+ }
1689
+ const explicitCandidates = [
1690
+ typeof element.salesNavCompanyUrl === "string" ? element.salesNavCompanyUrl : null,
1691
+ typeof element.sales_nav_company_url === "string" ? element.sales_nav_company_url : null,
1692
+ typeof element.url === "string" ? element.url : null
1693
+ ].filter(Boolean);
1694
+ for (const candidate of explicitCandidates) {
1695
+ const directMatch = candidate.match(/https:\/\/www\.linkedin\.com\/sales\/company\/[^/?#]+/i);
1696
+ if (directMatch) {
1697
+ return directMatch[0] ?? null;
1698
+ }
1699
+ }
1700
+ for (const value of collectNestedStrings(element)) {
1701
+ const directMatch = value.match(/https:\/\/www\.linkedin\.com\/sales\/company\/[^/?#]+/i);
1702
+ if (directMatch) {
1703
+ return directMatch[0] ?? null;
1704
+ }
1705
+ }
1706
+ const entityUrn = typeof element.entityUrn === "string" ? element.entityUrn : "";
1707
+ const idMatch = entityUrn.match(/\(([^,]+),/);
1708
+ return idMatch?.[1] ? `https://www.linkedin.com/sales/company/${idMatch[1]}` : null;
1100
1709
  }
1101
1710
  function collectNestedStrings(value, seen = new Set()) {
1102
1711
  if (value == null || seen.has(value)) {
@@ -1165,31 +1774,137 @@ function extractLinkedInCompanyNameFromSalesApiElement(element) {
1165
1774
  }
1166
1775
  return null;
1167
1776
  }
1168
- function extractLinkedInCompanyEmployeeCountFromSalesApiElement(element) {
1777
+ function extractLinkedInFullNameFromSalesApiElement(element) {
1169
1778
  if (!element) {
1170
1779
  return null;
1171
1780
  }
1172
- const numericCandidates = [
1173
- typeof element.employeeCount === "number" ? element.employeeCount : null,
1174
- typeof element.employeesOnLinkedInCount === "number" ? element.employeesOnLinkedInCount : null
1175
- ].filter((value) => Number.isFinite(value));
1176
- if (numericCandidates.length > 0) {
1177
- return Math.max(0, Math.trunc(numericCandidates[0] ?? 0));
1178
- }
1179
- for (const value of collectNestedStrings(element)) {
1180
- const match = value.match(/(\d[\d.,]*)\s+employees\b/i);
1181
- if (match) {
1182
- return Number(match[1].replace(/[.,]/g, ""));
1781
+ const directCandidates = [
1782
+ typeof element.fullName === "string" ? element.fullName : null,
1783
+ typeof element.name === "string" ? element.name : null
1784
+ ].filter(Boolean);
1785
+ for (const candidate of directCandidates) {
1786
+ const normalized = normalizeLookupWhitespace(candidate);
1787
+ if (normalized) {
1788
+ return normalized;
1183
1789
  }
1184
1790
  }
1185
- return null;
1791
+ const firstName = typeof element.firstName === "string" ? normalizeLookupWhitespace(element.firstName) : "";
1792
+ const lastName = typeof element.lastName === "string" ? normalizeLookupWhitespace(element.lastName) : "";
1793
+ const combined = normalizeLookupWhitespace(`${firstName} ${lastName}`);
1794
+ return combined || null;
1186
1795
  }
1187
- function buildLinkedInCompanyLookupVariants(params) {
1188
- const variants = [];
1189
- const seen = new Set();
1190
- const rawCandidates = [
1191
- normalizeLookupWhitespace(params.companyName),
1192
- normalizeLookupWhitespace(params.companyNameOriginal)
1796
+ function extractLinkedInTitleFromSalesApiElement(element) {
1797
+ if (!element) {
1798
+ return null;
1799
+ }
1800
+ const directCandidates = [
1801
+ typeof element.title === "string" ? element.title : null,
1802
+ typeof element.occupation === "string" ? element.occupation : null
1803
+ ].filter(Boolean);
1804
+ for (const candidate of directCandidates) {
1805
+ const normalized = normalizeLookupWhitespace(candidate);
1806
+ if (normalized) {
1807
+ return normalized;
1808
+ }
1809
+ }
1810
+ const currentPosition = Array.isArray(element.currentPositions) && element.currentPositions.length > 0
1811
+ ? element.currentPositions[0]
1812
+ : null;
1813
+ const currentTitle = currentPosition && typeof currentPosition.title === "string"
1814
+ ? normalizeLookupWhitespace(currentPosition.title)
1815
+ : "";
1816
+ return currentTitle || null;
1817
+ }
1818
+ function scoreLinkedInSalesApiElementMatch(contact, element) {
1819
+ const fullName = extractLinkedInFullNameFromSalesApiElement(element);
1820
+ const companyName = extractLinkedInCompanyNameFromSalesApiElement(Array.isArray(element?.currentPositions) && element.currentPositions.length > 0
1821
+ ? element.currentPositions[0]
1822
+ : element) ?? extractLinkedInCompanyNameFromSalesApiElement(element);
1823
+ const title = extractLinkedInTitleFromSalesApiElement(element);
1824
+ const expectedFullName = normalizeLooseMatchText(`${contact.firstName} ${contact.lastName}`);
1825
+ const candidateFullName = normalizeLooseMatchText(fullName);
1826
+ const expectedCompanies = Array.from(new Set([
1827
+ normalizeLooseMatchText(contact.companyNameOriginal ?? contact.companyName),
1828
+ normalizeLooseMatchText(aggressivelyCleanLookupCompanyName(contact.companyNameOriginal ?? contact.companyName)),
1829
+ normalizeLooseMatchText(normalizeLinkedInCompanyHandle(contact.linkedinCompanyUrl ?? "")?.replace(/[-_]+/g, " ") ?? ""),
1830
+ normalizeLooseMatchText((() => {
1831
+ const email = normalizeLookupWhitespace(contact.email);
1832
+ if (!email || isSyntheticLinkedInLookupEmail(email)) {
1833
+ return "";
1834
+ }
1835
+ return email.split("@")[1]?.replace(/^www\./i, "").split(".")[0] ?? "";
1836
+ })())
1837
+ ].filter(Boolean)));
1838
+ const candidateCompany = normalizeLooseMatchText(companyName);
1839
+ const candidateTitle = normalizeLooseMatchText(title);
1840
+ let score = 0;
1841
+ let exactNameMatch = false;
1842
+ let companyMatchCount = 0;
1843
+ if (expectedFullName && candidateFullName === expectedFullName) {
1844
+ score += 120;
1845
+ exactNameMatch = true;
1846
+ }
1847
+ else if (expectedFullName &&
1848
+ candidateFullName.includes(normalizeLooseMatchText(contact.firstName)) &&
1849
+ candidateFullName.includes(normalizeLooseMatchText(contact.lastName))) {
1850
+ score += 90;
1851
+ }
1852
+ for (const companyHint of expectedCompanies) {
1853
+ if (!companyHint) {
1854
+ continue;
1855
+ }
1856
+ if (candidateCompany === companyHint) {
1857
+ score += 40;
1858
+ companyMatchCount += 1;
1859
+ }
1860
+ else if (candidateCompany.includes(companyHint) || companyHint.includes(candidateCompany)) {
1861
+ score += 25;
1862
+ companyMatchCount += 1;
1863
+ }
1864
+ }
1865
+ const titleHints = [
1866
+ ...extractLookupTitleKeywords(contact.jobTitle),
1867
+ ...buildDeepDiveRoleSearchKeywords(contact.deepDiveRecommendedRole)
1868
+ ].slice(0, 6);
1869
+ for (const hint of titleHints) {
1870
+ if (hint && candidateTitle.includes(normalizeLooseMatchText(hint))) {
1871
+ score += 6;
1872
+ }
1873
+ }
1874
+ return {
1875
+ score,
1876
+ fullName,
1877
+ companyName,
1878
+ title,
1879
+ exactNameMatch,
1880
+ companyMatchCount
1881
+ };
1882
+ }
1883
+ function extractLinkedInCompanyEmployeeCountFromSalesApiElement(element) {
1884
+ if (!element) {
1885
+ return null;
1886
+ }
1887
+ const numericCandidates = [
1888
+ typeof element.employeeCount === "number" ? element.employeeCount : null,
1889
+ typeof element.employeesOnLinkedInCount === "number" ? element.employeesOnLinkedInCount : null
1890
+ ].filter((value) => Number.isFinite(value));
1891
+ if (numericCandidates.length > 0) {
1892
+ return Math.max(0, Math.trunc(numericCandidates[0] ?? 0));
1893
+ }
1894
+ for (const value of collectNestedStrings(element)) {
1895
+ const match = value.match(/(\d[\d.,]*)\s+employees\b/i);
1896
+ if (match) {
1897
+ return Number(match[1].replace(/[.,]/g, ""));
1898
+ }
1899
+ }
1900
+ return null;
1901
+ }
1902
+ function buildLinkedInCompanyLookupVariants(params) {
1903
+ const variants = [];
1904
+ const seen = new Set();
1905
+ const rawCandidates = [
1906
+ normalizeLookupWhitespace(params.companyName),
1907
+ normalizeLookupWhitespace(params.companyNameOriginal)
1193
1908
  ].filter(Boolean);
1194
1909
  const normalizedCandidates = rawCandidates.flatMap((candidate) => {
1195
1910
  const aggressive = aggressivelyCleanLookupCompanyName(candidate);
@@ -1213,8 +1928,1038 @@ function buildLinkedInCompanyLookupVariants(params) {
1213
1928
  }
1214
1929
  return variants;
1215
1930
  }
1931
+ function buildDirectCompanyContextKey(contact) {
1932
+ return normalizeLooseMatchText(contact.companyNameOriginal ?? contact.companyName);
1933
+ }
1934
+ async function resolveDirectLinkedInCompanyContexts(params) {
1935
+ const perCompanyBudgetMs = Math.min(params.timeoutMs, 10_000);
1936
+ const primaryByCompany = new Map();
1937
+ for (const contact of params.contacts) {
1938
+ const key = buildDirectCompanyContextKey(contact);
1939
+ if (!key || primaryByCompany.has(key)) {
1940
+ continue;
1941
+ }
1942
+ primaryByCompany.set(key, contact);
1943
+ }
1944
+ const contexts = new Map();
1945
+ for (const [companyKey, contact] of primaryByCompany.entries()) {
1946
+ const aliases = new Set();
1947
+ const addAlias = (value) => {
1948
+ const normalized = normalizeLookupWhitespace(value);
1949
+ if (!normalized) {
1950
+ return;
1951
+ }
1952
+ aliases.add(normalized);
1953
+ };
1954
+ addAlias(contact.companyNameOriginal);
1955
+ addAlias(contact.companyName);
1956
+ const existingHandle = normalizeLinkedInCompanyHandle(contact.linkedinCompanyUrl ?? "");
1957
+ if (existingHandle && !/^\d+$/.test(existingHandle)) {
1958
+ addAlias(existingHandle.replace(/[-_]+/g, " "));
1959
+ }
1960
+ let matchedCompanyUrl = contact.linkedinCompanyUrl ?? null;
1961
+ let matchedSalesNavCompanyUrl = null;
1962
+ let matchedCompanyName = null;
1963
+ let matchedCompanyEmployeeCount = null;
1964
+ const companyDeadline = Date.now() + perCompanyBudgetMs;
1965
+ const variants = buildLinkedInCompanyLookupVariants({
1966
+ contactId: contact.contact_id,
1967
+ companyName: contact.companyName,
1968
+ companyNameOriginal: contact.companyNameOriginal
1969
+ }).slice(0, 4);
1970
+ for (const variant of variants) {
1971
+ if (Date.now() >= companyDeadline) {
1972
+ break;
1973
+ }
1974
+ const controller = new AbortController();
1975
+ const timeout = setTimeout(controller.abort.bind(controller), Math.min(6_000, Math.max(1_000, companyDeadline - Date.now())));
1976
+ try {
1977
+ const response = await fetch(buildLinkedInAccountSearchApiUrl(variant.companyName), {
1978
+ method: "GET",
1979
+ signal: controller.signal,
1980
+ headers: {
1981
+ accept: "*/*",
1982
+ "accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
1983
+ "csrf-token": params.config.csrfToken,
1984
+ referer: "https://www.linkedin.com/sales/search/company",
1985
+ "sec-fetch-dest": "empty",
1986
+ "sec-fetch-mode": "cors",
1987
+ "sec-fetch-site": "same-origin",
1988
+ "user-agent": params.config.userAgent,
1989
+ "x-li-identity": params.config.identity,
1990
+ "x-li-lang": "en_US",
1991
+ "x-li-page-instance": "urn:li:page:d_sales2_search_accounts;13Jvve6kRGCao+iP0wwAag==",
1992
+ "x-restli-protocol-version": "2.0.0",
1993
+ cookie: params.config.cookie
1994
+ }
1995
+ });
1996
+ if (!response.ok) {
1997
+ if (response.status === 429) {
1998
+ break;
1999
+ }
2000
+ continue;
2001
+ }
2002
+ const data = (await response.json());
2003
+ const first = data.elements?.[0];
2004
+ const companyUrl = extractLinkedInCompanyUrlFromSalesApiElement(first);
2005
+ const salesNavCompanyUrl = extractLinkedInSalesNavCompanyUrlFromSalesApiElement(first);
2006
+ const companyName = extractLinkedInCompanyNameFromSalesApiElement(first);
2007
+ if (companyUrl || salesNavCompanyUrl || companyName) {
2008
+ matchedCompanyUrl = companyUrl ?? matchedCompanyUrl;
2009
+ matchedSalesNavCompanyUrl = salesNavCompanyUrl ?? matchedSalesNavCompanyUrl;
2010
+ matchedCompanyName = companyName ?? matchedCompanyName;
2011
+ matchedCompanyEmployeeCount = extractLinkedInCompanyEmployeeCountFromSalesApiElement(first);
2012
+ addAlias(companyName);
2013
+ addAlias(companyUrl ? normalizeLinkedInCompanyHandle(companyUrl)?.replace(/[-_]+/g, " ") : null);
2014
+ addAlias(salesNavCompanyUrl ? normalizeLookupWhitespace(salesNavCompanyUrl.split("/sales/company/")[1]?.split(/[/?#]/)[0] ?? "") : null);
2015
+ break;
2016
+ }
2017
+ }
2018
+ catch {
2019
+ // Try next company variant.
2020
+ }
2021
+ finally {
2022
+ clearTimeout(timeout);
2023
+ }
2024
+ }
2025
+ contexts.set(companyKey, {
2026
+ normalizedCompanyKey: companyKey,
2027
+ aliases: Array.from(aliases),
2028
+ linkedinCompanyUrl: matchedCompanyUrl,
2029
+ salesNavCompanyUrl: matchedSalesNavCompanyUrl,
2030
+ matchedCompanyName,
2031
+ matchedCompanyEmployeeCount
2032
+ });
2033
+ }
2034
+ return contexts;
2035
+ }
2036
+ function buildPublicLinkedInCompanySearchUrl(companyName) {
2037
+ const baseUrl = process.env.SALESPROMPTER_LINKEDIN_COMPANY_SEARCH_BASE_URL?.trim() ||
2038
+ "https://duckduckgo.com/html/";
2039
+ const url = new URL(baseUrl);
2040
+ url.searchParams.set("q", `site:linkedin.com/company "${companyName}"`);
2041
+ return url.toString();
2042
+ }
2043
+ function getSerperApiKey(env = process.env) {
2044
+ return env.SALESPROMPTER_SERPER_API_KEY?.trim() || env.SERPER_API_KEY?.trim() || "";
2045
+ }
2046
+ function getSerperSearchEndpoint(env = process.env) {
2047
+ return env.SALESPROMPTER_SERPER_SEARCH_URL?.trim() || "https://google.serper.dev/search";
2048
+ }
2049
+ function buildSerperLinkedInCompanyQueries(companyName) {
2050
+ const normalized = normalizeLookupWhitespace(companyName);
2051
+ const coreName = normalized.split(/\s*[-,|]\s*/)[0]?.trim() || normalized.trim();
2052
+ const searchable = normalizeLookupCompanyForSearch(normalized);
2053
+ const loose = normalizeLooseMatchText(normalized).replace(/\s+/g, " ").trim();
2054
+ const keywordTokens = loose
2055
+ .split(/\s+/)
2056
+ .filter((token) => token.length >= 4)
2057
+ .filter((token) => !["oder", "with", "from", "handel", "beratung"].includes(token))
2058
+ .slice(0, 4);
2059
+ const keywordQuery = keywordTokens.join(" ");
2060
+ return Array.from(new Set([
2061
+ `site:linkedin.com/company "${companyName}"`,
2062
+ `site:linkedin.com/company "${coreName}"`,
2063
+ `site:linkedin.com/company ${searchable} linkedin`,
2064
+ `site:linkedin.com/company ${loose} linkedin`,
2065
+ keywordQuery ? `site:linkedin.com/company ${keywordQuery} linkedin` : ""
2066
+ ])).filter((query) => query.length > 0);
2067
+ }
2068
+ function extractLinkedInCompanySearchCandidates(bodyText) {
2069
+ const candidates = new Set();
2070
+ const directMatches = bodyText.match(/https:\/\/www\.linkedin\.com\/company\/[^"'&<>\s)]+/gi) ?? [];
2071
+ for (const match of directMatches) {
2072
+ const handle = normalizeLinkedInCompanyHandle(match);
2073
+ if (handle) {
2074
+ candidates.add(normalizeLinkedInCompanyPage(handle));
2075
+ }
2076
+ }
2077
+ const encodedMatches = bodyText.match(/https?%3A%2F%2Fwww\.linkedin\.com%2Fcompany%2F[^"'&<>\s)]+/gi) ?? [];
2078
+ for (const match of encodedMatches) {
2079
+ try {
2080
+ const decoded = decodeURIComponent(match);
2081
+ const handle = normalizeLinkedInCompanyHandle(decoded);
2082
+ if (handle) {
2083
+ candidates.add(normalizeLinkedInCompanyPage(handle));
2084
+ }
2085
+ }
2086
+ catch {
2087
+ // Ignore malformed encoded fragments from search result pages.
2088
+ }
2089
+ }
2090
+ return Array.from(candidates);
2091
+ }
2092
+ function extractSerperLinkedInCompanyCandidates(payload) {
2093
+ if (!payload || typeof payload !== "object") {
2094
+ return [];
2095
+ }
2096
+ const organic = "organic" in payload && Array.isArray(payload.organic)
2097
+ ? (payload.organic ?? [])
2098
+ : [];
2099
+ const seen = new Set();
2100
+ const candidates = [];
2101
+ for (const result of organic) {
2102
+ if (!result || typeof result !== "object") {
2103
+ continue;
2104
+ }
2105
+ const link = "link" in result && typeof result.link === "string"
2106
+ ? result.link
2107
+ : "";
2108
+ const handle = normalizeLinkedInCompanyHandle(link);
2109
+ if (handle) {
2110
+ const url = normalizeLinkedInCompanyPage(handle);
2111
+ if (!seen.has(url)) {
2112
+ seen.add(url);
2113
+ candidates.push({
2114
+ url,
2115
+ title: "title" in result && typeof result.title === "string"
2116
+ ? normalizeLookupWhitespace(result.title)
2117
+ : "",
2118
+ snippet: "snippet" in result && typeof result.snippet === "string"
2119
+ ? normalizeLookupWhitespace(result.snippet)
2120
+ : ""
2121
+ });
2122
+ }
2123
+ }
2124
+ }
2125
+ return candidates;
2126
+ }
2127
+ const linkedInCompanyHintCache = new Map();
2128
+ const linkedInProfilePageSignalCache = new Map();
2129
+ const linkedInCompanyPageSignalCache = new Map();
2130
+ const serperSearchCache = new Map();
2131
+ let serperCreditsExhausted = false;
2132
+ function extractKeywordPhrases(value) {
2133
+ const normalized = normalizeLookupWhitespace(value);
2134
+ if (!normalized) {
2135
+ return [];
2136
+ }
2137
+ const phrases = new Set();
2138
+ const push = (candidate) => {
2139
+ const cleaned = normalizeLookupWhitespace(candidate);
2140
+ if (!cleaned || cleaned.length < 3) {
2141
+ return;
2142
+ }
2143
+ phrases.add(cleaned);
2144
+ };
2145
+ push(normalized);
2146
+ push(normalizeLookupCompanyForSearch(normalized));
2147
+ push(aggressivelyCleanLookupCompanyName(normalized));
2148
+ const titleStripped = normalized
2149
+ .replace(/\|\s*linkedin$/i, "")
2150
+ .replace(/\|\s*overview$/i, "")
2151
+ .replace(/\b(linkedin|home|about|posts|see all details)\b/gi, " ")
2152
+ .replace(/\s+/g, " ")
2153
+ .trim();
2154
+ push(titleStripped);
2155
+ const parts = titleStripped
2156
+ .split(/[|,·•:()/-]+/)
2157
+ .map((part) => normalizeLookupWhitespace(part))
2158
+ .filter(Boolean);
2159
+ for (const part of parts) {
2160
+ push(part);
2161
+ }
2162
+ const looseTokens = normalizeLooseMatchText(titleStripped)
2163
+ .split(/\s+/)
2164
+ .filter((token) => token.length >= 4)
2165
+ .filter((token) => ![
2166
+ "group",
2167
+ "holding",
2168
+ "services",
2169
+ "service",
2170
+ "consulting",
2171
+ "gmbh",
2172
+ "publishing",
2173
+ "company",
2174
+ "linkedin",
2175
+ "deutschland"
2176
+ ].includes(token));
2177
+ if (looseTokens.length > 0) {
2178
+ push(looseTokens[0]);
2179
+ push(looseTokens.slice(0, 2).join(" "));
2180
+ push(looseTokens.slice(-2).join(" "));
2181
+ }
2182
+ return Array.from(phrases);
2183
+ }
2184
+ async function buildLinkedInProfileCompanyHints(contact, timeoutMs) {
2185
+ const phrases = new Set();
2186
+ const keywords = new Set();
2187
+ const addPhrase = (value) => {
2188
+ for (const phrase of extractKeywordPhrases(value)) {
2189
+ phrases.add(phrase);
2190
+ const looseTokens = normalizeLooseMatchText(phrase)
2191
+ .split(/\s+/)
2192
+ .filter((token) => token.length >= 4)
2193
+ .filter((token) => ![
2194
+ "group",
2195
+ "holding",
2196
+ "services",
2197
+ "service",
2198
+ "consulting",
2199
+ "gmbh",
2200
+ "publishing",
2201
+ "company",
2202
+ "linkedin",
2203
+ "deutschland"
2204
+ ].includes(token));
2205
+ for (const token of looseTokens.slice(0, 5)) {
2206
+ keywords.add(token);
2207
+ }
2208
+ if (looseTokens.length > 1) {
2209
+ keywords.add(looseTokens.slice(0, 2).join(" "));
2210
+ keywords.add(looseTokens.slice(-2).join(" "));
2211
+ }
2212
+ }
2213
+ };
2214
+ addPhrase(contact.companyNameOriginal ?? contact.companyName);
2215
+ const linkedInHandle = normalizeLinkedInCompanyHandle(contact.linkedinCompanyUrl ?? "");
2216
+ if (linkedInHandle && !/^\d+$/.test(linkedInHandle)) {
2217
+ addPhrase(linkedInHandle.replace(/[-_]+/g, " "));
2218
+ }
2219
+ const normalizedEmail = normalizeLookupWhitespace(contact.email);
2220
+ const emailDomain = normalizedEmail && !isSyntheticLinkedInLookupEmail(normalizedEmail)
2221
+ ? normalizedEmail.split("@")[1] ?? ""
2222
+ : "";
2223
+ if (emailDomain) {
2224
+ const normalizedDomain = emailDomain.replace(/^www\./i, "");
2225
+ keywords.add(normalizedDomain);
2226
+ const host = normalizedDomain.split(".")[0] ?? "";
2227
+ if (host) {
2228
+ addPhrase(host.replace(/[-_]+/g, " "));
2229
+ }
2230
+ }
2231
+ const companyUrl = contact.linkedinCompanyUrl?.trim();
2232
+ if (companyUrl) {
2233
+ const cacheKey = companyUrl.replace(/\/$/, "");
2234
+ let cachedHints = linkedInCompanyHintCache.get(cacheKey);
2235
+ if (!cachedHints) {
2236
+ const signals = await fetchLinkedInCompanyPageSignals(companyUrl, timeoutMs);
2237
+ cachedHints = signals ? [...extractKeywordPhrases(signals.title), ...extractKeywordPhrases(signals.description)] : [];
2238
+ linkedInCompanyHintCache.set(cacheKey, cachedHints);
2239
+ }
2240
+ for (const hint of cachedHints) {
2241
+ addPhrase(hint);
2242
+ }
2243
+ }
2244
+ return {
2245
+ phrases: Array.from(phrases)
2246
+ .map((value) => normalizeLookupWhitespace(value))
2247
+ .filter((value) => value.length > 0),
2248
+ keywords: Array.from(keywords)
2249
+ .map((value) => normalizeLookupWhitespace(value))
2250
+ .filter((value) => value.length > 0)
2251
+ };
2252
+ }
2253
+ async function buildSerperLinkedInProfileQueries(contact, timeoutMs) {
2254
+ const fullName = normalizeLookupWhitespace(`${contact.firstName} ${contact.lastName}`);
2255
+ const title = normalizeLookupWhitespace(contact.jobTitle);
2256
+ const queryEntries = [];
2257
+ const seenQueries = new Set();
2258
+ const pushQuery = (query, score) => {
2259
+ const normalized = normalizeLookupWhitespace(query);
2260
+ if (!normalized) {
2261
+ return;
2262
+ }
2263
+ const key = normalized.toLowerCase();
2264
+ if (seenQueries.has(key)) {
2265
+ return;
2266
+ }
2267
+ seenQueries.add(key);
2268
+ queryEntries.push({ query: normalized, score });
2269
+ };
2270
+ const { phrases, keywords } = await buildLinkedInProfileCompanyHints(contact, timeoutMs);
2271
+ const enrichedPhrases = new Set(phrases);
2272
+ const enrichedKeywords = new Set(keywords);
2273
+ const preferredPhrases = [];
2274
+ const normalizedEmail = normalizeLookupWhitespace(contact.email);
2275
+ const trustedEmailDomain = normalizedEmail && !isSyntheticLinkedInLookupEmail(normalizedEmail)
2276
+ ? normalizedEmail.split("@")[1]?.replace(/^www\./i, "") ?? ""
2277
+ : "";
2278
+ const emailHost = trustedEmailDomain.split(".")[0] ?? "";
2279
+ const emailDomain = trustedEmailDomain;
2280
+ const linkedInHandle = normalizeLinkedInCompanyHandle(contact.linkedinCompanyUrl ?? "")?.replace(/[-_]+/g, " ") ?? "";
2281
+ if (contact.linkedinCompanyUrl?.trim()) {
2282
+ const companySignals = await fetchLinkedInCompanyPageSignals(contact.linkedinCompanyUrl.trim(), timeoutMs);
2283
+ for (const phrase of [
2284
+ ...extractKeywordPhrases(companySignals?.title),
2285
+ ...extractKeywordPhrases(companySignals?.description)
2286
+ ]) {
2287
+ enrichedPhrases.add(phrase);
2288
+ preferredPhrases.push(phrase);
2289
+ const looseTokens = normalizeLooseMatchText(phrase)
2290
+ .split(/\s+/)
2291
+ .filter((token) => token.length >= 4)
2292
+ .filter((token) => ![
2293
+ "group",
2294
+ "holding",
2295
+ "services",
2296
+ "service",
2297
+ "consulting",
2298
+ "gmbh",
2299
+ "publishing",
2300
+ "company",
2301
+ "linkedin",
2302
+ "deutschland"
2303
+ ].includes(token));
2304
+ for (const token of looseTokens.slice(0, 4)) {
2305
+ enrichedKeywords.add(token);
2306
+ }
2307
+ if (looseTokens.length > 1) {
2308
+ enrichedKeywords.add(looseTokens.slice(0, 2).join(" "));
2309
+ }
2310
+ }
2311
+ }
2312
+ const phrasePriority = (value) => {
2313
+ const loose = normalizeLooseMatchText(value);
2314
+ const tokenCount = loose.split(/\s+/).filter(Boolean).length;
2315
+ let score = 0;
2316
+ if (emailHost && loose.includes(normalizeLooseMatchText(emailHost)))
2317
+ score += 80;
2318
+ if (linkedInHandle && loose.includes(normalizeLooseMatchText(linkedInHandle)))
2319
+ score += 60;
2320
+ if (tokenCount >= 1 && tokenCount <= 4)
2321
+ score += 40;
2322
+ if (!/\b(gmbh|holding|services|service|consulting|kg|co)\b/i.test(value))
2323
+ score += 20;
2324
+ if (tokenCount > 7)
2325
+ score -= 40;
2326
+ return score;
2327
+ };
2328
+ const keywordPriority = (value) => {
2329
+ const loose = normalizeLooseMatchText(value);
2330
+ let score = 0;
2331
+ if (emailHost && loose.includes(normalizeLooseMatchText(emailHost)))
2332
+ score += 80;
2333
+ if (linkedInHandle && loose.includes(normalizeLooseMatchText(linkedInHandle)))
2334
+ score += 60;
2335
+ if (value.includes("."))
2336
+ score += 20;
2337
+ if (loose.split(/\s+/).filter(Boolean).length <= 2)
2338
+ score += 10;
2339
+ return score;
2340
+ };
2341
+ const rankedPhrases = [...enrichedPhrases].sort((left, right) => {
2342
+ const preferredDelta = Number(preferredPhrases.includes(right)) - Number(preferredPhrases.includes(left));
2343
+ if (preferredDelta !== 0) {
2344
+ return preferredDelta;
2345
+ }
2346
+ return phrasePriority(right) - phrasePriority(left);
2347
+ });
2348
+ const cleanPhrases = rankedPhrases.slice(0, 6);
2349
+ const fallbackKeywords = new Set(enrichedKeywords);
2350
+ for (const phrase of cleanPhrases) {
2351
+ const looseTokens = normalizeLooseMatchText(phrase)
2352
+ .split(/\s+/)
2353
+ .filter((token) => token.length >= 4)
2354
+ .filter((token) => ![
2355
+ "group",
2356
+ "holding",
2357
+ "services",
2358
+ "service",
2359
+ "consulting",
2360
+ "gmbh",
2361
+ "publishing",
2362
+ "company",
2363
+ "linkedin",
2364
+ "deutschland"
2365
+ ].includes(token));
2366
+ for (const token of looseTokens.slice(0, 3)) {
2367
+ fallbackKeywords.add(token);
2368
+ }
2369
+ if (looseTokens.length > 1) {
2370
+ fallbackKeywords.add(looseTokens.slice(0, 2).join(" "));
2371
+ }
2372
+ }
2373
+ if (emailHost) {
2374
+ fallbackKeywords.add(emailHost);
2375
+ }
2376
+ if (emailDomain) {
2377
+ fallbackKeywords.add(emailDomain);
2378
+ }
2379
+ if (linkedInHandle) {
2380
+ fallbackKeywords.add(linkedInHandle);
2381
+ }
2382
+ const cleanKeywords = [...fallbackKeywords]
2383
+ .sort((left, right) => keywordPriority(right) - keywordPriority(left))
2384
+ .slice(0, 5);
2385
+ cleanKeywords.forEach((keyword, index) => {
2386
+ const keywordScore = 260 - index * 15;
2387
+ pushQuery(`site:linkedin.com/in "${fullName}" ${keyword} linkedin`, keywordScore);
2388
+ pushQuery(`site:linkedin.com/in ${fullName} ${keyword} linkedin`, keywordScore - 5);
2389
+ if (title) {
2390
+ pushQuery(`site:linkedin.com/in "${fullName}" ${keyword} "${title}"`, keywordScore - 10);
2391
+ }
2392
+ });
2393
+ cleanPhrases.forEach((companyName, index) => {
2394
+ const phraseScore = 180 - index * 10;
2395
+ pushQuery(`site:linkedin.com/in "${fullName}" "${companyName}"`, phraseScore);
2396
+ pushQuery(`site:linkedin.com/in ${fullName} ${companyName} linkedin`, phraseScore - 5);
2397
+ if (title) {
2398
+ pushQuery(`site:linkedin.com/in "${fullName}" "${companyName}" "${title}"`, phraseScore - 10);
2399
+ pushQuery(`site:linkedin.com/in ${fullName} ${companyName} ${title} linkedin`, phraseScore - 15);
2400
+ }
2401
+ });
2402
+ if (emailDomain) {
2403
+ pushQuery(`site:linkedin.com/in "${fullName}" "${emailDomain}" linkedin`, 240);
2404
+ }
2405
+ pushQuery(`site:linkedin.com/in "${fullName}" linkedin`, 50);
2406
+ if (title) {
2407
+ pushQuery(`site:linkedin.com/in "${fullName}" "${title}" linkedin`, 40);
2408
+ }
2409
+ return queryEntries
2410
+ .sort((left, right) => right.score - left.score)
2411
+ .map((entry) => entry.query);
2412
+ }
2413
+ function extractPublicLinkedInProfileSearchCandidates(bodyText) {
2414
+ const candidates = new Set();
2415
+ const directMatches = bodyText.match(/https:\/\/(?:(?:www|[a-z]{2})\.)?linkedin\.com\/in\/[^"'&<>\s)]+/gi) ?? [];
2416
+ for (const match of directMatches) {
2417
+ const normalized = normalizePublicLinkedInProfileUrl(match);
2418
+ if (normalized) {
2419
+ candidates.add(normalized);
2420
+ }
2421
+ }
2422
+ const encodedMatches = bodyText.match(/https?%3A%2F%2F(?:(?:www|[a-z]{2})\.)?linkedin\.com%2Fin%2F[^"'&<>\s)]+/gi) ?? [];
2423
+ for (const match of encodedMatches) {
2424
+ try {
2425
+ const decoded = decodeURIComponent(match);
2426
+ const normalized = normalizePublicLinkedInProfileUrl(decoded);
2427
+ if (normalized) {
2428
+ candidates.add(normalized);
2429
+ }
2430
+ }
2431
+ catch {
2432
+ // Ignore malformed encoded fragments.
2433
+ }
2434
+ }
2435
+ return Array.from(candidates);
2436
+ }
2437
+ function buildPublicLinkedInProfileSearchUrl(query) {
2438
+ const baseUrl = process.env.SALESPROMPTER_LINKEDIN_PROFILE_SEARCH_BASE_URL?.trim() ||
2439
+ "https://duckduckgo.com/html/";
2440
+ const url = new URL(baseUrl);
2441
+ url.searchParams.set("q", query);
2442
+ return url.toString();
2443
+ }
2444
+ async function fetchSerperSearchResults(query, num, timeoutMs) {
2445
+ if (serperCreditsExhausted) {
2446
+ return null;
2447
+ }
2448
+ const apiKey = getSerperApiKey();
2449
+ if (!apiKey) {
2450
+ return null;
2451
+ }
2452
+ const cacheKey = `${query}::${num}`;
2453
+ if (serperSearchCache.has(cacheKey)) {
2454
+ return serperSearchCache.get(cacheKey) ?? null;
2455
+ }
2456
+ const controller = new AbortController();
2457
+ const timeout = setTimeout(() => controller.abort(), Math.min(timeoutMs, 12_000));
2458
+ try {
2459
+ const response = await fetch(getSerperSearchEndpoint(), {
2460
+ method: "POST",
2461
+ signal: controller.signal,
2462
+ headers: {
2463
+ "X-API-KEY": apiKey,
2464
+ "Content-Type": "application/json"
2465
+ },
2466
+ body: JSON.stringify({ q: query, num })
2467
+ });
2468
+ if (!response.ok) {
2469
+ const bodyText = await response.text().catch(() => "");
2470
+ if (response.status === 400 &&
2471
+ /not enough credits/i.test(bodyText)) {
2472
+ serperCreditsExhausted = true;
2473
+ }
2474
+ serperSearchCache.set(cacheKey, null);
2475
+ return null;
2476
+ }
2477
+ const parsed = await response.json();
2478
+ serperSearchCache.set(cacheKey, parsed);
2479
+ return parsed;
2480
+ }
2481
+ catch {
2482
+ return null;
2483
+ }
2484
+ finally {
2485
+ clearTimeout(timeout);
2486
+ }
2487
+ }
2488
+ function extractSerperLinkedInProfileCandidates(payload) {
2489
+ if (!payload || typeof payload !== "object") {
2490
+ return [];
2491
+ }
2492
+ const organic = "organic" in payload && Array.isArray(payload.organic)
2493
+ ? (payload.organic ?? [])
2494
+ : [];
2495
+ const seen = new Set();
2496
+ const candidates = [];
2497
+ for (const result of organic) {
2498
+ if (!result || typeof result !== "object")
2499
+ continue;
2500
+ const link = "link" in result && typeof result.link === "string"
2501
+ ? result.link
2502
+ : "";
2503
+ const normalized = normalizePublicLinkedInProfileUrl(link);
2504
+ if (normalized) {
2505
+ const canonical = normalized.replace(/\/$/, "");
2506
+ if (!seen.has(canonical)) {
2507
+ seen.add(canonical);
2508
+ candidates.push({
2509
+ url: canonical,
2510
+ title: "title" in result && typeof result.title === "string"
2511
+ ? normalizeLookupWhitespace(result.title)
2512
+ : "",
2513
+ snippet: "snippet" in result && typeof result.snippet === "string"
2514
+ ? normalizeLookupWhitespace(result.snippet)
2515
+ : ""
2516
+ });
2517
+ }
2518
+ }
2519
+ }
2520
+ return candidates;
2521
+ }
2522
+ async function fetchLinkedInProfilePageSignals(url, timeoutMs) {
2523
+ const cacheKey = normalizePublicLinkedInProfileUrl(url)?.replace(/\/$/, "") ?? url.replace(/\/$/, "");
2524
+ if (linkedInProfilePageSignalCache.has(cacheKey)) {
2525
+ return linkedInProfilePageSignalCache.get(cacheKey) ?? null;
2526
+ }
2527
+ const controller = new AbortController();
2528
+ const timeout = setTimeout(() => controller.abort(), Math.min(timeoutMs, 12_000));
2529
+ try {
2530
+ const targetUrl = rewriteLinkedInUrlForConfiguredBase(url);
2531
+ const response = await fetch(targetUrl, {
2532
+ method: "GET",
2533
+ signal: controller.signal,
2534
+ headers: {
2535
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
2536
+ }
2537
+ });
2538
+ const html = await response.text();
2539
+ const finalUrl = normalizePublicLinkedInProfileUrl(url) ||
2540
+ normalizePublicLinkedInProfileUrl(response.url || url);
2541
+ if (!finalUrl) {
2542
+ return null;
2543
+ }
2544
+ const title = decodeHtmlEntities(html.match(/<title[^>]*>([^<]+)/i)?.[1] || "") ||
2545
+ decodeHtmlEntities(html.match(/<meta[^>]+property="og:title"[^>]+content="([^"]+)/i)?.[1] || "");
2546
+ const description = decodeHtmlEntities(html.match(/<meta[^>]+name="description"[^>]+content="([^"]+)/i)?.[1] || "");
2547
+ const bodyText = decodeHtmlEntities(html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " "));
2548
+ const unavailableText = normalizeLooseMatchText(`${title} ${description} ${bodyText}`);
2549
+ const unavailable = response.status >= 400 ||
2550
+ unavailableText.includes("page not found") ||
2551
+ unavailableText.includes("profile not found") ||
2552
+ unavailableText.includes("member profile") && unavailableText.includes("not available");
2553
+ const result = {
2554
+ normalizedUrl: finalUrl.replace(/\/$/, ""),
2555
+ title: normalizeLookupWhitespace(title),
2556
+ description: normalizeLookupWhitespace(description),
2557
+ bodyText: normalizeLookupWhitespace(bodyText),
2558
+ unavailable
2559
+ };
2560
+ linkedInProfilePageSignalCache.set(cacheKey, result);
2561
+ return result;
2562
+ }
2563
+ catch {
2564
+ linkedInProfilePageSignalCache.set(cacheKey, null);
2565
+ return null;
2566
+ }
2567
+ finally {
2568
+ clearTimeout(timeout);
2569
+ }
2570
+ }
2571
+ function scoreLinkedInProfilePageSignals(contact, signals) {
2572
+ const fullName = normalizeLooseMatchText(`${contact.firstName} ${contact.lastName}`);
2573
+ const companyHints = [
2574
+ normalizeLooseMatchText(contact.companyNameOriginal ?? contact.companyName),
2575
+ normalizeLooseMatchText(aggressivelyCleanLookupCompanyName(contact.companyNameOriginal ?? contact.companyName))
2576
+ ].filter(Boolean);
2577
+ const titleHint = normalizeLooseMatchText(contact.jobTitle);
2578
+ const haystack = normalizeLooseMatchText(`${signals.title} ${signals.description} ${signals.bodyText}`);
2579
+ let score = 0;
2580
+ if (fullName && haystack.includes(fullName))
2581
+ score += 120;
2582
+ for (const hint of companyHints) {
2583
+ if (hint && haystack.includes(hint))
2584
+ score += 30;
2585
+ }
2586
+ if (titleHint) {
2587
+ const titleWords = titleHint.split(/\s+/).filter((token) => token.length >= 4).slice(0, 4);
2588
+ score += titleWords.filter((token) => haystack.includes(token)).length * 8;
2589
+ }
2590
+ const slug = signals.normalizedUrl.split("/in/")[1]?.replace(/\/$/, "") ?? "";
2591
+ const slugText = normalizeLooseMatchText(slug.replace(/[-_]+/g, " "));
2592
+ if (fullName && slugText.includes(contact.firstName.toLowerCase()) && slugText.includes(contact.lastName.toLowerCase())) {
2593
+ score += 40;
2594
+ }
2595
+ return score;
2596
+ }
2597
+ function analyzeSerperLinkedInProfileCandidate(contact, candidate) {
2598
+ const fullName = normalizeLooseMatchText(`${contact.firstName} ${contact.lastName}`);
2599
+ const titleHint = normalizeLooseMatchText(contact.jobTitle);
2600
+ const companyTokens = [
2601
+ normalizeLooseMatchText(contact.companyNameOriginal ?? contact.companyName),
2602
+ normalizeLooseMatchText(aggressivelyCleanLookupCompanyName(contact.companyNameOriginal ?? contact.companyName)),
2603
+ normalizeLooseMatchText(normalizeLinkedInCompanyHandle(contact.linkedinCompanyUrl ?? "")?.replace(/[-_]+/g, " ") ?? ""),
2604
+ normalizeLooseMatchText((() => {
2605
+ const normalizedEmail = normalizeLookupWhitespace(contact.email);
2606
+ if (!normalizedEmail || isSyntheticLinkedInLookupEmail(normalizedEmail)) {
2607
+ return "";
2608
+ }
2609
+ return normalizedEmail.split("@")[1]?.replace(/^www\./i, "").split(".")[0] ?? "";
2610
+ })())
2611
+ ].filter(Boolean);
2612
+ const haystack = normalizeLooseMatchText(`${candidate.title} ${candidate.snippet}`);
2613
+ let score = 0;
2614
+ let companyMatches = 0;
2615
+ let titleMatches = 0;
2616
+ if (fullName && haystack.includes(fullName))
2617
+ score += 120;
2618
+ for (const token of companyTokens) {
2619
+ if (!token)
2620
+ continue;
2621
+ if (haystack.includes(token)) {
2622
+ companyMatches += 1;
2623
+ score += token.split(/\s+/).length <= 2 ? 30 : 20;
2624
+ }
2625
+ }
2626
+ if (titleHint) {
2627
+ const titleWords = titleHint.split(/\s+/).filter((token) => token.length >= 4).slice(0, 4);
2628
+ titleMatches = titleWords.filter((token) => haystack.includes(token)).length;
2629
+ score += titleMatches * 8;
2630
+ }
2631
+ const slugText = normalizeLooseMatchText(candidate.url.split("/in/")[1]?.replace(/\/$/, "").replace(/[-_]+/g, " ") ?? "");
2632
+ if (fullName &&
2633
+ slugText.includes(contact.firstName.toLowerCase()) &&
2634
+ slugText.includes(contact.lastName.toLowerCase()) &&
2635
+ (companyMatches > 0 || titleMatches > 0)) {
2636
+ score += 40;
2637
+ }
2638
+ return { score, companyMatches, titleMatches };
2639
+ }
2640
+ async function searchSerperLinkedInProfileUrl(contact, timeoutMs, options) {
2641
+ if (!contact.firstName || !contact.lastName) {
2642
+ return null;
2643
+ }
2644
+ const maxQueries = options?.maxQueries && Number.isFinite(options.maxQueries) && options.maxQueries > 0
2645
+ ? Math.trunc(options.maxQueries)
2646
+ : Number.POSITIVE_INFINITY;
2647
+ for (const query of (await buildSerperLinkedInProfileQueries(contact, timeoutMs)).slice(0, maxQueries)) {
2648
+ try {
2649
+ const parsed = await fetchSerperSearchResults(query, 5, timeoutMs);
2650
+ if (!parsed) {
2651
+ continue;
2652
+ }
2653
+ const candidates = extractSerperLinkedInProfileCandidates(parsed);
2654
+ let bestUrl = null;
2655
+ let bestScore = 0;
2656
+ for (const candidate of candidates) {
2657
+ const serperAnalysis = analyzeSerperLinkedInProfileCandidate(contact, candidate);
2658
+ const serperScore = serperAnalysis.score;
2659
+ if (serperScore >= 150 && (serperAnalysis.companyMatches > 0 || serperAnalysis.titleMatches > 0)) {
2660
+ return candidate.url;
2661
+ }
2662
+ const signals = await fetchLinkedInProfilePageSignals(candidate.url, timeoutMs);
2663
+ if (!signals || signals.unavailable) {
2664
+ if (serperScore > bestScore) {
2665
+ bestScore = serperScore;
2666
+ bestUrl = candidate.url;
2667
+ }
2668
+ continue;
2669
+ }
2670
+ const score = Math.max(serperScore, scoreLinkedInProfilePageSignals(contact, signals));
2671
+ if (score > bestScore) {
2672
+ bestScore = score;
2673
+ bestUrl = signals.normalizedUrl;
2674
+ }
2675
+ }
2676
+ if (bestUrl && bestScore >= 130) {
2677
+ return bestUrl;
2678
+ }
2679
+ }
2680
+ catch {
2681
+ // Continue with the next query variant.
2682
+ }
2683
+ }
2684
+ return searchPublicLinkedInProfileUrl(contact, timeoutMs, {
2685
+ maxQueries: Math.min(Number.isFinite(maxQueries) ? maxQueries : 4, 4)
2686
+ });
2687
+ }
2688
+ function decodeHtmlEntities(value) {
2689
+ return value
2690
+ .replace(/&amp;/gi, "&")
2691
+ .replace(/&quot;/gi, '"')
2692
+ .replace(/&#39;/gi, "'")
2693
+ .replace(/&lt;/gi, "<")
2694
+ .replace(/&gt;/gi, ">");
2695
+ }
2696
+ async function fetchLinkedInCompanyPageSignals(url, timeoutMs) {
2697
+ const cacheKey = url.replace(/\/$/, "");
2698
+ if (linkedInCompanyPageSignalCache.has(cacheKey)) {
2699
+ return linkedInCompanyPageSignalCache.get(cacheKey) ?? null;
2700
+ }
2701
+ const controller = new AbortController();
2702
+ const timeout = setTimeout(() => controller.abort(), Math.min(timeoutMs, 12_000));
2703
+ try {
2704
+ const response = await fetch(url, {
2705
+ method: "GET",
2706
+ signal: controller.signal,
2707
+ headers: {
2708
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
2709
+ }
2710
+ });
2711
+ const html = await response.text();
2712
+ const finalUrl = response.url || url;
2713
+ const title = decodeHtmlEntities(html.match(/<title[^>]*>([^<]+)/i)?.[1] || "") ||
2714
+ decodeHtmlEntities(html.match(/<meta[^>]+property="og:title"[^>]+content="([^"]+)/i)?.[1] || "");
2715
+ const description = decodeHtmlEntities(html.match(/<meta[^>]+name="description"[^>]+content="([^"]+)/i)?.[1] || "");
2716
+ const bodyText = decodeHtmlEntities(html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " "));
2717
+ const unavailableText = normalizeLooseMatchText(`${title} ${description} ${bodyText}`);
2718
+ const unavailable = response.status >= 400 ||
2719
+ unavailableText.includes("page not found") ||
2720
+ unavailableText.includes("this page does not exist") ||
2721
+ unavailableText.includes("page isnt available");
2722
+ const result = {
2723
+ normalizedUrl: normalizeLinkedInCompanyHandle(finalUrl ?? "") || normalizeLinkedInCompanyHandle(url)
2724
+ ? normalizeLinkedInCompanyPage(normalizeLinkedInCompanyHandle(finalUrl ?? "") ?? normalizeLinkedInCompanyHandle(url) ?? "")
2725
+ : finalUrl,
2726
+ title: normalizeLookupWhitespace(title),
2727
+ description: normalizeLookupWhitespace(description),
2728
+ bodyText: normalizeLookupWhitespace(bodyText),
2729
+ unavailable
2730
+ };
2731
+ linkedInCompanyPageSignalCache.set(cacheKey, result);
2732
+ return result;
2733
+ }
2734
+ catch {
2735
+ linkedInCompanyPageSignalCache.set(cacheKey, null);
2736
+ return null;
2737
+ }
2738
+ finally {
2739
+ clearTimeout(timeout);
2740
+ }
2741
+ }
2742
+ async function searchPublicLinkedInProfileUrl(contact, timeoutMs, options) {
2743
+ const maxQueries = options?.maxQueries && Number.isFinite(options.maxQueries) && options.maxQueries > 0
2744
+ ? Math.trunc(options.maxQueries)
2745
+ : 4;
2746
+ const queries = (await buildSerperLinkedInProfileQueries(contact, timeoutMs)).slice(0, maxQueries);
2747
+ for (const query of queries) {
2748
+ const controller = new AbortController();
2749
+ const timeout = setTimeout(() => controller.abort(), Math.min(timeoutMs, 12_000));
2750
+ try {
2751
+ const response = await fetch(buildPublicLinkedInProfileSearchUrl(query), {
2752
+ method: "GET",
2753
+ signal: controller.signal,
2754
+ headers: {
2755
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
2756
+ }
2757
+ });
2758
+ if (!response.ok) {
2759
+ continue;
2760
+ }
2761
+ const bodyText = await response.text();
2762
+ const candidates = extractPublicLinkedInProfileSearchCandidates(bodyText);
2763
+ let bestUrl = null;
2764
+ let bestScore = 0;
2765
+ for (const candidateUrl of candidates.slice(0, 5)) {
2766
+ const signals = await fetchLinkedInProfilePageSignals(candidateUrl, timeoutMs);
2767
+ if (!signals || signals.unavailable) {
2768
+ continue;
2769
+ }
2770
+ const score = scoreLinkedInProfilePageSignals(contact, signals);
2771
+ if (score > bestScore) {
2772
+ bestScore = score;
2773
+ bestUrl = signals.normalizedUrl;
2774
+ }
2775
+ }
2776
+ if (bestUrl && bestScore >= 130) {
2777
+ return bestUrl;
2778
+ }
2779
+ }
2780
+ catch {
2781
+ // Continue with the next query variant.
2782
+ }
2783
+ finally {
2784
+ clearTimeout(timeout);
2785
+ }
2786
+ }
2787
+ return null;
2788
+ }
2789
+ function scoreLinkedInCompanyPageSignals(companyName, signals) {
2790
+ const inputTokens = normalizeLooseMatchText(companyName).split(/\s+/).filter((token) => token.length >= 4);
2791
+ const haystack = normalizeLooseMatchText(`${signals.title} ${signals.description}`);
2792
+ let score = 0;
2793
+ for (const token of inputTokens) {
2794
+ if (haystack.includes(token)) {
2795
+ score += 12;
2796
+ }
2797
+ }
2798
+ if (signals.description && normalizeLooseMatchText(signals.description).includes(normalizeLooseMatchText(companyName))) {
2799
+ score += 50;
2800
+ }
2801
+ return score;
2802
+ }
2803
+ function scoreSerperLinkedInCompanyCandidate(companyName, candidate) {
2804
+ const inputTokens = normalizeLooseMatchText(companyName).split(/\s+/).filter((token) => token.length >= 4);
2805
+ const haystack = normalizeLooseMatchText(`${candidate.title} ${candidate.snippet}`);
2806
+ let score = scoreLinkedInCompanyUrlCandidate(companyName, candidate.url);
2807
+ for (const token of inputTokens) {
2808
+ if (haystack.includes(token)) {
2809
+ score += 12;
2810
+ }
2811
+ }
2812
+ if (haystack.includes(normalizeLooseMatchText(aggressivelyCleanLookupCompanyName(companyName)))) {
2813
+ score += 40;
2814
+ }
2815
+ return score;
2816
+ }
2817
+ function scoreLinkedInCompanyUrlCandidate(companyName, url) {
2818
+ const handle = normalizeLinkedInCompanyHandle(url);
2819
+ if (!handle || /^\d+$/.test(handle)) {
2820
+ return 0;
2821
+ }
2822
+ const normalizedCompanyWords = normalizeLookupCompanyForSearch(companyName)
2823
+ .split(/\s+/)
2824
+ .filter((part) => part.length >= 3);
2825
+ const normalizedCompany = normalizedCompanyWords.join("");
2826
+ const aggressiveCompany = aggressivelyCleanLookupCompanyName(companyName).replace(/\s+/g, "");
2827
+ const normalizedHandle = handle.toLowerCase().replace(/[-_]/g, "");
2828
+ const slugCompany = (slugify(companyName) || "").replace(/-/g, "");
2829
+ let score = 0;
2830
+ if (normalizedHandle === normalizedCompany || normalizedHandle === aggressiveCompany || normalizedHandle === slugCompany) {
2831
+ score += 100;
2832
+ }
2833
+ if (normalizedCompany &&
2834
+ (normalizedHandle.includes(normalizedCompany) || normalizedCompany.includes(normalizedHandle))) {
2835
+ score += 60;
2836
+ }
2837
+ if (aggressiveCompany &&
2838
+ (normalizedHandle.includes(aggressiveCompany) || aggressiveCompany.includes(normalizedHandle))) {
2839
+ score += 40;
2840
+ }
2841
+ if (normalizedCompanyWords.length > 0) {
2842
+ const primaryWord = normalizedCompanyWords[0] ?? "";
2843
+ if (primaryWord && normalizedHandle.includes(primaryWord)) {
2844
+ score += 35;
2845
+ }
2846
+ const overlap = normalizedCompanyWords.filter((word) => normalizedHandle.includes(word)).length;
2847
+ score += Math.min(30, overlap * 10);
2848
+ }
2849
+ return score;
2850
+ }
2851
+ async function searchPublicLinkedInCompanyUrl(companyName, timeoutMs) {
2852
+ const controller = new AbortController();
2853
+ const timeout = setTimeout(() => controller.abort(), Math.min(timeoutMs, 15_000));
2854
+ try {
2855
+ const response = await fetch(buildPublicLinkedInCompanySearchUrl(companyName), {
2856
+ method: "GET",
2857
+ signal: controller.signal,
2858
+ headers: {
2859
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
2860
+ }
2861
+ });
2862
+ if (!response.ok) {
2863
+ return null;
2864
+ }
2865
+ const bodyText = await response.text();
2866
+ const candidates = extractLinkedInCompanySearchCandidates(bodyText);
2867
+ const ranked = candidates
2868
+ .map((url) => ({ url, score: scoreLinkedInCompanyUrlCandidate(companyName, url) }))
2869
+ .filter((candidate) => candidate.score > 0)
2870
+ .sort((left, right) => right.score - left.score);
2871
+ return ranked[0]?.url ?? candidates[0] ?? null;
2872
+ }
2873
+ catch (error) {
2874
+ if (error.name === "AbortError") {
2875
+ return null;
2876
+ }
2877
+ return null;
2878
+ }
2879
+ finally {
2880
+ clearTimeout(timeout);
2881
+ }
2882
+ }
2883
+ async function searchSerperLinkedInCompanyUrl(companyName, timeoutMs) {
2884
+ const apiKey = getSerperApiKey();
2885
+ if (!apiKey) {
2886
+ return null;
2887
+ }
2888
+ for (const query of buildSerperLinkedInCompanyQueries(companyName)) {
2889
+ const controller = new AbortController();
2890
+ const timeout = setTimeout(() => controller.abort(), Math.min(timeoutMs, 15_000));
2891
+ try {
2892
+ const response = await fetch(getSerperSearchEndpoint(), {
2893
+ method: "POST",
2894
+ signal: controller.signal,
2895
+ headers: {
2896
+ "Content-Type": "application/json",
2897
+ "X-API-KEY": apiKey
2898
+ },
2899
+ body: JSON.stringify({
2900
+ q: query,
2901
+ num: 5
2902
+ })
2903
+ });
2904
+ if (!response.ok) {
2905
+ continue;
2906
+ }
2907
+ const parsed = (await response.json());
2908
+ const candidates = extractSerperLinkedInCompanyCandidates(parsed);
2909
+ const ranked = candidates
2910
+ .map((candidate) => ({
2911
+ ...candidate,
2912
+ score: scoreSerperLinkedInCompanyCandidate(companyName, candidate)
2913
+ }))
2914
+ .filter((candidate) => candidate.score > 0)
2915
+ .sort((left, right) => right.score - left.score);
2916
+ if (ranked[0] && ranked[0].score >= 80) {
2917
+ return ranked[0].url;
2918
+ }
2919
+ let anySignalsFetched = false;
2920
+ let bestValidated = null;
2921
+ for (const candidate of ranked.slice(0, 3)) {
2922
+ const signals = await fetchLinkedInCompanyPageSignals(candidate.url, timeoutMs);
2923
+ if (!signals || signals.unavailable) {
2924
+ continue;
2925
+ }
2926
+ anySignalsFetched = true;
2927
+ const validationScore = scoreLinkedInCompanyPageSignals(companyName, signals);
2928
+ if (validationScore >= 24) {
2929
+ const combinedScore = candidate.score + validationScore;
2930
+ if (!bestValidated || combinedScore > bestValidated.score) {
2931
+ bestValidated = {
2932
+ url: signals.normalizedUrl,
2933
+ score: combinedScore
2934
+ };
2935
+ }
2936
+ }
2937
+ }
2938
+ if (bestValidated) {
2939
+ return bestValidated.url;
2940
+ }
2941
+ if (!anySignalsFetched && ranked[0]?.url) {
2942
+ return ranked[0].url;
2943
+ }
2944
+ }
2945
+ catch (error) {
2946
+ if (error.name === "AbortError") {
2947
+ continue;
2948
+ }
2949
+ }
2950
+ finally {
2951
+ clearTimeout(timeout);
2952
+ }
2953
+ }
2954
+ return null;
2955
+ }
1216
2956
  async function invokeLinkedInUrlEnrichmentDirect(params) {
1217
2957
  const config = await readLinkedInDirectLookupConfig();
2958
+ const companyContexts = await resolveDirectLinkedInCompanyContexts({
2959
+ contacts: params.contacts.filter((contact) => !contact.isVariation),
2960
+ timeoutMs: params.timeoutMs,
2961
+ config
2962
+ });
1218
2963
  const groupedContacts = new Map();
1219
2964
  for (const contact of params.contacts) {
1220
2965
  const key = contact.email?.trim().toLowerCase() || `contact:${contact.contact_id}`;
@@ -1223,15 +2968,25 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
1223
2968
  groupedContacts.set(key, existing);
1224
2969
  }
1225
2970
  const results = [];
1226
- let rateLimited = false;
2971
+ const perAttemptTimeoutMs = params.perAttemptTimeoutMs && Number.isFinite(params.perAttemptTimeoutMs) && params.perAttemptTimeoutMs > 0
2972
+ ? Math.trunc(params.perAttemptTimeoutMs)
2973
+ : Math.min(params.timeoutMs, 8_000);
2974
+ const perContactBudgetMs = params.perContactBudgetMs && Number.isFinite(params.perContactBudgetMs) && params.perContactBudgetMs > 0
2975
+ ? Math.trunc(params.perContactBudgetMs)
2976
+ : Math.min(params.timeoutMs, 15_000);
2977
+ const rateLimitCooldownMs = Math.max(750, Math.min(3_000, Math.trunc(perAttemptTimeoutMs / 2)));
2978
+ const maxRateLimitCooldowns = 4;
2979
+ let rateLimitCooldownUntil = 0;
2980
+ let consecutiveRateLimitCount = 0;
2981
+ let totalRateLimitCooldowns = 0;
1227
2982
  for (const variations of groupedContacts.values()) {
1228
2983
  const primary = variations.find((contact) => !contact.isVariation) ?? variations[0];
1229
2984
  const blankPerson = !primary?.firstName.trim() || !primary?.lastName.trim();
1230
- if (rateLimited) {
2985
+ if (totalRateLimitCooldowns >= maxRateLimitCooldowns) {
1231
2986
  results.push({
1232
2987
  contact_id: primary.contact_id,
1233
2988
  linkedin_url: null,
1234
- error: "LinkedIn rate limit"
2989
+ error: "LinkedIn rate limit budget exhausted"
1235
2990
  });
1236
2991
  continue;
1237
2992
  }
@@ -1244,11 +2999,24 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
1244
2999
  continue;
1245
3000
  }
1246
3001
  let matchedUrl = null;
3002
+ let matchedSalesNavUrl = null;
3003
+ let matchedFullName = null;
3004
+ let matchedCompanyName = null;
3005
+ let matchedTitle = null;
1247
3006
  let lastError = null;
3007
+ const contactDeadline = Date.now() + perContactBudgetMs;
3008
+ const companyContext = companyContexts.get(buildDirectCompanyContextKey(primary));
1248
3009
  for (const candidate of variations) {
1249
- for (const searchVariant of buildLinkedInLookupSearchVariants(candidate)) {
3010
+ for (const searchVariant of await buildLinkedInLookupSearchVariants(candidate, params.timeoutMs, companyContext?.aliases ?? [])) {
3011
+ if (Date.now() < rateLimitCooldownUntil) {
3012
+ await new Promise((resolve) => setTimeout(resolve, rateLimitCooldownUntil - Date.now()));
3013
+ }
3014
+ if (Date.now() >= contactDeadline) {
3015
+ lastError = lastError || "Direct lookup budget exhausted";
3016
+ break;
3017
+ }
1250
3018
  const controller = new AbortController();
1251
- const timeout = setTimeout(controller.abort.bind(controller), Math.min(params.timeoutMs, 20_000));
3019
+ const timeout = setTimeout(controller.abort.bind(controller), Math.min(perAttemptTimeoutMs, Math.max(1_000, contactDeadline - Date.now())));
1252
3020
  try {
1253
3021
  const response = await fetch(buildLinkedInSalesApiUrl(searchVariant), {
1254
3022
  method: "GET",
@@ -1269,19 +3037,52 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
1269
3037
  }
1270
3038
  });
1271
3039
  if (response.status === 429) {
1272
- rateLimited = true;
1273
3040
  lastError = "LinkedIn rate limit";
3041
+ consecutiveRateLimitCount += 1;
3042
+ totalRateLimitCooldowns += 1;
3043
+ rateLimitCooldownUntil =
3044
+ Date.now() + Math.min(15_000, rateLimitCooldownMs * Math.max(1, consecutiveRateLimitCount));
3045
+ if (totalRateLimitCooldowns >= maxRateLimitCooldowns) {
3046
+ break;
3047
+ }
1274
3048
  break;
1275
3049
  }
1276
3050
  if (!response.ok) {
1277
3051
  lastError = `LinkedIn returned ${response.status}`;
1278
3052
  continue;
1279
3053
  }
3054
+ consecutiveRateLimitCount = 0;
3055
+ rateLimitCooldownUntil = 0;
1280
3056
  const data = (await response.json());
1281
3057
  const profilesFound = data.paging?.total ?? 0;
1282
3058
  if (profilesFound > 0) {
1283
- matchedUrl = extractLinkedInProfileUrlFromSalesApiElement(data.elements?.[0]) ?? null;
1284
- if (matchedUrl) {
3059
+ const bestCandidate = (data.elements ?? [])
3060
+ .map((element) => ({
3061
+ element,
3062
+ ...scoreLinkedInSalesApiElementMatch(candidate, element)
3063
+ }))
3064
+ .sort((left, right) => right.score - left.score)[0];
3065
+ const hasTrustedCompanyContext = Boolean(candidate.linkedinCompanyUrl ||
3066
+ companyContext?.linkedinCompanyUrl ||
3067
+ companyContext?.matchedCompanyName);
3068
+ const hasTrustedEmailContext = Boolean(candidate.email && !isSyntheticLinkedInLookupEmail(candidate.email));
3069
+ const acceptBestCandidate = Boolean(bestCandidate &&
3070
+ (bestCandidate.score >= 140 ||
3071
+ (bestCandidate.exactNameMatch &&
3072
+ (bestCandidate.companyMatchCount > 0 || hasTrustedCompanyContext || hasTrustedEmailContext))));
3073
+ if (bestCandidate && acceptBestCandidate) {
3074
+ matchedUrl = extractLinkedInProfileUrlFromSalesApiElement(bestCandidate.element) ?? null;
3075
+ matchedSalesNavUrl = extractLinkedInSalesNavLeadUrlFromSalesApiElement(bestCandidate.element) ?? null;
3076
+ matchedFullName = bestCandidate.fullName;
3077
+ matchedCompanyName = bestCandidate.companyName;
3078
+ matchedTitle = bestCandidate.title;
3079
+ }
3080
+ else {
3081
+ lastError = bestCandidate
3082
+ ? `LinkedIn top result score too low (${bestCandidate.score})`
3083
+ : "LinkedIn returned no usable results";
3084
+ }
3085
+ if (matchedUrl || matchedSalesNavUrl) {
1285
3086
  break;
1286
3087
  }
1287
3088
  }
@@ -1292,27 +3093,36 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
1292
3093
  finally {
1293
3094
  clearTimeout(timeout);
1294
3095
  }
1295
- if (matchedUrl || rateLimited) {
3096
+ if (matchedUrl || matchedSalesNavUrl || totalRateLimitCooldowns >= maxRateLimitCooldowns) {
1296
3097
  break;
1297
3098
  }
1298
3099
  }
1299
- if (matchedUrl || rateLimited) {
3100
+ if (matchedUrl || matchedSalesNavUrl || totalRateLimitCooldowns >= maxRateLimitCooldowns) {
3101
+ break;
3102
+ }
3103
+ if (Date.now() >= contactDeadline) {
1300
3104
  break;
1301
3105
  }
1302
3106
  }
1303
3107
  results.push({
1304
3108
  contact_id: primary.contact_id,
1305
- linkedin_url: matchedUrl,
1306
- error: matchedUrl ? null : lastError
3109
+ linkedin_url: matchedUrl ?? matchedSalesNavUrl,
3110
+ sales_nav_profile_url: matchedSalesNavUrl,
3111
+ matched_full_name: matchedFullName,
3112
+ matched_company_name: matchedCompanyName,
3113
+ matched_title: matchedTitle,
3114
+ error: matchedUrl || matchedSalesNavUrl ? null : lastError
1307
3115
  });
1308
3116
  }
1309
3117
  return {
1310
3118
  success: true,
1311
- contacts: results
3119
+ contacts: results,
3120
+ companyContexts: Array.from(companyContexts.values())
1312
3121
  };
1313
3122
  }
1314
3123
  async function invokeLinkedInCompanyEnrichmentDirect(params) {
1315
3124
  const config = await readLinkedInDirectLookupConfig();
3125
+ const precomputedContextByKey = new Map((params.precomputedContexts ?? []).map((context) => [context.normalizedCompanyKey, context]));
1316
3126
  const primaryContacts = new Map();
1317
3127
  for (const contact of params.contacts) {
1318
3128
  const existing = primaryContacts.get(contact.contact_id);
@@ -1336,10 +3146,23 @@ async function invokeLinkedInCompanyEnrichmentDirect(params) {
1336
3146
  companyName: contact.companyName,
1337
3147
  companyNameOriginal: contact.companyNameOriginal
1338
3148
  });
1339
- let matchedCompanyUrl = null;
1340
- let matchedCompanyName = null;
1341
- let matchedCompanyEmployeeCount = null;
3149
+ const precomputedContext = precomputedContextByKey.get(buildDirectCompanyContextKey(contact));
3150
+ let matchedCompanyUrl = precomputedContext?.linkedinCompanyUrl ?? null;
3151
+ let matchedSalesNavCompanyUrl = precomputedContext?.salesNavCompanyUrl ?? null;
3152
+ let matchedCompanyName = precomputedContext?.matchedCompanyName ?? null;
3153
+ let matchedCompanyEmployeeCount = precomputedContext?.matchedCompanyEmployeeCount ?? null;
1342
3154
  let lastError = null;
3155
+ if (matchedCompanyUrl || matchedSalesNavCompanyUrl || matchedCompanyName) {
3156
+ results.push({
3157
+ contact_id: contact.contact_id,
3158
+ linkedin_company_url: matchedCompanyUrl,
3159
+ sales_nav_company_url: matchedSalesNavCompanyUrl,
3160
+ matched_company_name: matchedCompanyName,
3161
+ matched_company_employee_count: matchedCompanyEmployeeCount,
3162
+ error: null
3163
+ });
3164
+ continue;
3165
+ }
1343
3166
  for (const variant of variants) {
1344
3167
  const controller = new AbortController();
1345
3168
  const timeout = setTimeout(controller.abort.bind(controller), Math.min(params.timeoutMs, 20_000));
@@ -1375,8 +3198,10 @@ async function invokeLinkedInCompanyEnrichmentDirect(params) {
1375
3198
  const data = (await response.json());
1376
3199
  const first = data.elements?.[0];
1377
3200
  const companyUrl = extractLinkedInCompanyUrlFromSalesApiElement(first);
1378
- if (companyUrl) {
3201
+ const salesNavCompanyUrl = extractLinkedInSalesNavCompanyUrlFromSalesApiElement(first);
3202
+ if (companyUrl || salesNavCompanyUrl) {
1379
3203
  matchedCompanyUrl = companyUrl;
3204
+ matchedSalesNavCompanyUrl = salesNavCompanyUrl;
1380
3205
  matchedCompanyName = extractLinkedInCompanyNameFromSalesApiElement(first);
1381
3206
  matchedCompanyEmployeeCount = extractLinkedInCompanyEmployeeCountFromSalesApiElement(first);
1382
3207
  break;
@@ -1395,9 +3220,10 @@ async function invokeLinkedInCompanyEnrichmentDirect(params) {
1395
3220
  results.push({
1396
3221
  contact_id: contact.contact_id,
1397
3222
  linkedin_company_url: matchedCompanyUrl,
3223
+ sales_nav_company_url: matchedSalesNavCompanyUrl,
1398
3224
  matched_company_name: matchedCompanyName,
1399
3225
  matched_company_employee_count: matchedCompanyEmployeeCount,
1400
- error: matchedCompanyUrl ? null : lastError
3226
+ error: matchedCompanyUrl || matchedSalesNavCompanyUrl ? null : lastError
1401
3227
  });
1402
3228
  }
1403
3229
  return {
@@ -1478,6 +3304,113 @@ async function invokeLinkedInUrlEnrichmentWorkflow(params) {
1478
3304
  clearTimeout(timeout);
1479
3305
  }
1480
3306
  }
3307
+ function normalizeWorkflowLinkedInUrlResult(params) {
3308
+ const inputContactIds = new Set(params.contacts.map((contact) => contact.contact_id));
3309
+ const contactIdsBySyntheticEmail = new Map(params.contacts
3310
+ .filter((contact) => contact.email)
3311
+ .map((contact) => [String(contact.email).toLowerCase(), contact.contact_id]));
3312
+ const contactIdsByNormalizedIdentity = new Map(params.contacts
3313
+ .filter((contact) => !contact.isVariation)
3314
+ .map((contact) => {
3315
+ const fullName = normalizeLooseMatchText(normalizeLookupWhitespace(`${contact.firstName} ${contact.lastName}`));
3316
+ const companyName = normalizeLooseMatchText(contact.companyNameOriginal ?? contact.companyName);
3317
+ return [`${fullName}|${companyName}`, contact.contact_id];
3318
+ })
3319
+ .filter(([key]) => key !== "|"));
3320
+ const normalizedNameCounts = new Map();
3321
+ for (const contact of params.contacts) {
3322
+ if (contact.isVariation)
3323
+ continue;
3324
+ const fullName = normalizeLooseMatchText(normalizeLookupWhitespace(`${contact.firstName} ${contact.lastName}`));
3325
+ if (!fullName)
3326
+ continue;
3327
+ normalizedNameCounts.set(fullName, (normalizedNameCounts.get(fullName) ?? 0) + 1);
3328
+ }
3329
+ const contactIdsByNormalizedName = new Map(params.contacts
3330
+ .filter((contact) => !contact.isVariation)
3331
+ .map((contact) => {
3332
+ const fullName = normalizeLooseMatchText(normalizeLookupWhitespace(`${contact.firstName} ${contact.lastName}`));
3333
+ return [fullName, contact.contact_id];
3334
+ })
3335
+ .filter(([fullName]) => Boolean(fullName) && (normalizedNameCounts.get(fullName) ?? 0) === 1));
3336
+ const rowsByContactId = new Map();
3337
+ const body = params.parsedBody && typeof params.parsedBody === "object" && !Array.isArray(params.parsedBody)
3338
+ ? params.parsedBody
3339
+ : null;
3340
+ const workflowRows = [
3341
+ ...(Array.isArray(body?.contacts) ? body?.contacts : []),
3342
+ ...(Array.isArray(body?.profiles) ? body?.profiles : [])
3343
+ ];
3344
+ for (const contact of workflowRows) {
3345
+ const fullNameCandidate = normalizeLookupWhitespace(typeof contact.full_name === "string"
3346
+ ? contact.full_name
3347
+ : typeof contact.fullName === "string"
3348
+ ? contact.fullName
3349
+ : typeof contact.name === "string"
3350
+ ? contact.name
3351
+ : [contact.first_name, contact.last_name]
3352
+ .filter((value) => typeof value === "string" && value.trim().length > 0)
3353
+ .join(" "));
3354
+ const companyNameCandidate = normalizeLookupWhitespace(typeof contact.company_name === "string"
3355
+ ? contact.company_name
3356
+ : typeof contact.companyName === "string"
3357
+ ? contact.companyName
3358
+ : typeof contact.current_company === "string"
3359
+ ? contact.current_company
3360
+ : "");
3361
+ const normalizedIdentityKey = `${normalizeLooseMatchText(fullNameCandidate)}|${normalizeLooseMatchText(companyNameCandidate)}`;
3362
+ const explicitContactId = typeof contact.contact_id === "string"
3363
+ ? contact.contact_id
3364
+ : typeof contact.contact_id === "number"
3365
+ ? String(contact.contact_id)
3366
+ : "";
3367
+ const emailKey = typeof contact.email === "string" ? contact.email.toLowerCase() : "";
3368
+ const contactId = (inputContactIds.has(explicitContactId) ? explicitContactId : "") ||
3369
+ contactIdsBySyntheticEmail.get(emailKey) ||
3370
+ contactIdsByNormalizedIdentity.get(normalizedIdentityKey) ||
3371
+ contactIdsByNormalizedName.get(normalizeLooseMatchText(fullNameCandidate)) ||
3372
+ "";
3373
+ const linkedinUrl = normalizePublicLinkedInProfileUrl(typeof contact.linkedin_profile_url === "string"
3374
+ ? contact.linkedin_profile_url
3375
+ : typeof contact.linkedinProfileUrl === "string"
3376
+ ? contact.linkedinProfileUrl
3377
+ : typeof contact.default_profile_url === "string"
3378
+ ? contact.default_profile_url
3379
+ : typeof contact.defaultProfileUrl === "string"
3380
+ ? contact.defaultProfileUrl
3381
+ : typeof contact.linkedin_url === "string"
3382
+ ? contact.linkedin_url
3383
+ : typeof contact.linkedinUrl === "string"
3384
+ ? contact.linkedinUrl
3385
+ : null);
3386
+ const salesNavProfileUrl = normalizeSalesNavLeadUrl(typeof contact.sales_nav_profile_url === "string"
3387
+ ? contact.sales_nav_profile_url
3388
+ : typeof contact.salesNavProfileUrl === "string"
3389
+ ? contact.salesNavProfileUrl
3390
+ : typeof contact.linkedin_url === "string"
3391
+ ? contact.linkedin_url
3392
+ : typeof contact.linkedinUrl === "string"
3393
+ ? contact.linkedinUrl
3394
+ : null) ?? null;
3395
+ const regularCompanyHandle = normalizeLinkedInCompanyHandle(typeof contact.regular_company_url === "string"
3396
+ ? contact.regular_company_url
3397
+ : typeof contact.regularCompanyUrl === "string"
3398
+ ? contact.regularCompanyUrl
3399
+ : "");
3400
+ const linkedinCompanyUrl = extractLinkedInCompanyUrlFromSalesApiElement(contact) ??
3401
+ (regularCompanyHandle ? normalizeLinkedInCompanyPage(regularCompanyHandle) : null);
3402
+ const salesNavCompanyUrl = extractLinkedInSalesNavCompanyUrlFromSalesApiElement(contact);
3403
+ if (contactId) {
3404
+ rowsByContactId.set(contactId, {
3405
+ linkedinUrl: linkedinUrl ?? salesNavProfileUrl,
3406
+ salesNavProfileUrl,
3407
+ linkedinCompanyUrl,
3408
+ salesNavCompanyUrl
3409
+ });
3410
+ }
3411
+ }
3412
+ return rowsByContactId;
3413
+ }
1481
3414
  async function fetchSalesNavLookupCandidates(params) {
1482
3415
  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
1483
3416
  const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
@@ -1523,7 +3456,8 @@ async function fetchSalesNavLookupCandidates(params) {
1523
3456
  }
1524
3457
  async function resolveLinkedInUrlsFromSalesNavRows(params) {
1525
3458
  const results = [];
1526
- for (const [index, row] of params.rows.entries()) {
3459
+ for (const row of params.rows) {
3460
+ const contactId = normalizeLinkedInLookupField(row.contactId) ?? `${results.length + 1}`;
1527
3461
  const candidates = await fetchSalesNavLookupCandidates({
1528
3462
  companyName: row.companyName,
1529
3463
  orgId: params.orgId
@@ -1559,6 +3493,7 @@ async function resolveLinkedInUrlsFromSalesNavRows(params) {
1559
3493
  return right.score - left.score || Number(Boolean(rightUrl)) - Number(Boolean(leftUrl));
1560
3494
  });
1561
3495
  const best = ranked[0]?.candidate;
3496
+ const salesNavProfileUrl = best?.salesNavProfileUrl ?? null;
1562
3497
  const linkedinUrl = best?.linkedInProfileUrl ?? best?.salesNavProfileUrl ?? null;
1563
3498
  const linkedinCompanyUrl = (() => {
1564
3499
  const handle = normalizeLinkedInCompanyHandle(best?.regularCompanyUrl ?? "") ??
@@ -1569,17 +3504,23 @@ async function resolveLinkedInUrlsFromSalesNavRows(params) {
1569
3504
  const numericCompanyUrl = typeof best?.companyUrl === "string" ? best.companyUrl.trim() : "";
1570
3505
  return numericCompanyUrl.length > 0 ? numericCompanyUrl : null;
1571
3506
  })();
3507
+ const salesNavCompanyUrl = typeof best?.companyUrl === "string" && /\/sales\/company\//i.test(best.companyUrl)
3508
+ ? best.companyUrl
3509
+ : null;
3510
+ const existingLinkedInCompanyUrl = row.linkedinCompanyUrl?.trim() || null;
1572
3511
  results.push({
1573
3512
  clientId: row.clientId,
1574
3513
  fullName: row.fullName,
1575
3514
  companyName: row.companyName,
1576
3515
  linkedinUrl,
1577
- linkedinCompanyUrl,
3516
+ salesNavProfileUrl,
3517
+ linkedinCompanyUrl: linkedinCompanyUrl ?? existingLinkedInCompanyUrl,
3518
+ salesNavCompanyUrl,
1578
3519
  found: Boolean(linkedinUrl),
1579
- companyFound: Boolean(linkedinCompanyUrl),
1580
- contactId: String(index + 1),
3520
+ companyFound: Boolean(linkedinCompanyUrl ?? existingLinkedInCompanyUrl),
3521
+ contactId,
1581
3522
  source: linkedinUrl ? "salesnav-supabase" : null,
1582
- companySource: linkedinCompanyUrl ? "salesnav-supabase" : null,
3523
+ companySource: linkedinCompanyUrl ? "salesnav-supabase" : existingLinkedInCompanyUrl ? "input" : null,
1583
3524
  matchedFullName: best?.fullName ?? null,
1584
3525
  matchedCompanyName: best?.companyName ?? null,
1585
3526
  matchedTitle: best?.title ?? null,
@@ -1589,6 +3530,223 @@ async function resolveLinkedInUrlsFromSalesNavRows(params) {
1589
3530
  }
1590
3531
  return results;
1591
3532
  }
3533
+ function shouldUseSalesNavRowPrepass(params) {
3534
+ const env = params.env ?? process.env;
3535
+ const explicit = env.SALESPROMPTER_LINKEDIN_ROW_PREPASS?.trim().toLowerCase();
3536
+ if (explicit === "0" || explicit === "false" || explicit === "off") {
3537
+ return false;
3538
+ }
3539
+ if (explicit === "1" || explicit === "true" || explicit === "on") {
3540
+ return true;
3541
+ }
3542
+ const hasOrgId = Boolean(params.orgId?.trim());
3543
+ const hasSupabase = Boolean(env.NEXT_PUBLIC_SUPABASE_URL?.trim() && env.SUPABASE_SERVICE_ROLE_KEY?.trim());
3544
+ const maxRows = Number(env.SALESPROMPTER_LINKEDIN_ROW_PREPASS_MAX_ROWS ?? 200);
3545
+ if (!hasOrgId || !hasSupabase) {
3546
+ return false;
3547
+ }
3548
+ return params.rows.length <= (Number.isFinite(maxRows) && maxRows > 0 ? maxRows : 200);
3549
+ }
3550
+ function shouldUseDirectPeopleLookup(params) {
3551
+ const env = params.env ?? process.env;
3552
+ const explicit = env.SALESPROMPTER_LINKEDIN_DIRECT_PROFILE_LOOKUP?.trim().toLowerCase();
3553
+ if (explicit === "0" || explicit === "false" || explicit === "off") {
3554
+ return false;
3555
+ }
3556
+ if (explicit === "1" || explicit === "true" || explicit === "on") {
3557
+ return true;
3558
+ }
3559
+ const maxRows = Number(env.SALESPROMPTER_LINKEDIN_DIRECT_PROFILE_MAX_ROWS ?? 50);
3560
+ return params.rowCount <= (Number.isFinite(maxRows) && maxRows > 0 ? maxRows : 50);
3561
+ }
3562
+ function shouldUseWorkflowPeopleLookup(params) {
3563
+ const env = params.env ?? process.env;
3564
+ const explicit = env.SALESPROMPTER_LINKEDIN_WORKFLOW_PROFILE_LOOKUP?.trim().toLowerCase();
3565
+ if (explicit === "0" || explicit === "false" || explicit === "off") {
3566
+ return false;
3567
+ }
3568
+ if (explicit === "1" || explicit === "true" || explicit === "on") {
3569
+ return true;
3570
+ }
3571
+ const hasSerper = Boolean(getSerperApiKey(env));
3572
+ const maxRows = Number(env.SALESPROMPTER_LINKEDIN_WORKFLOW_PROFILE_MAX_ROWS ?? (hasSerper ? 75 : 250));
3573
+ return params.rowCount <= (Number.isFinite(maxRows) && maxRows > 0 ? maxRows : hasSerper ? 75 : 250);
3574
+ }
3575
+ function shouldUseBulkProfileResolutionStrategy(params) {
3576
+ const env = params.env ?? process.env;
3577
+ const explicit = env.SALESPROMPTER_LINKEDIN_BULK_MODE?.trim().toLowerCase();
3578
+ if (explicit === "0" || explicit === "false" || explicit === "off") {
3579
+ return false;
3580
+ }
3581
+ if (explicit === "1" || explicit === "true" || explicit === "on") {
3582
+ return true;
3583
+ }
3584
+ const minRows = Number(env.SALESPROMPTER_LINKEDIN_BULK_MODE_MIN_ROWS ?? 75);
3585
+ return params.rowCount >= (Number.isFinite(minRows) && minRows > 0 ? minRows : 75);
3586
+ }
3587
+ function resolveLinkedInBulkStrategyConfig(params) {
3588
+ const env = params.env ?? process.env;
3589
+ const bulkMode = shouldUseBulkProfileResolutionStrategy({
3590
+ rowCount: params.rowCount,
3591
+ env
3592
+ });
3593
+ const serperConcurrencyDefault = bulkMode ? 12 : 6;
3594
+ const serperConcurrency = Number(env.SALESPROMPTER_LINKEDIN_SERPER_CONCURRENCY ?? serperConcurrencyDefault);
3595
+ const serperMaxQueriesDefault = bulkMode ? 4 : 8;
3596
+ const serperMaxQueries = Number(env.SALESPROMPTER_LINKEDIN_SERPER_MAX_QUERIES ?? serperMaxQueriesDefault);
3597
+ const workflowStageBudgetDefault = bulkMode ? 8_000 : 15_000;
3598
+ const workflowStageBudgetMs = Number(env.SALESPROMPTER_LINKEDIN_WORKFLOW_STAGE_TIMEOUT_MS ?? workflowStageBudgetDefault);
3599
+ const serperStageBudgetDefault = bulkMode
3600
+ ? Math.max(15_000, Math.min(params.timeoutMs * 2, 45_000))
3601
+ : Math.max(10_000, Math.min(params.timeoutMs, 20_000));
3602
+ const serperStageBudgetMs = Number(env.SALESPROMPTER_LINKEDIN_SERPER_STAGE_TIMEOUT_MS ?? serperStageBudgetDefault);
3603
+ const bulkDirectProfileMaxRowsDefault = 0;
3604
+ const bulkDirectProfileMaxRows = Number(env.SALESPROMPTER_LINKEDIN_BULK_DIRECT_PROFILE_MAX_ROWS ?? bulkDirectProfileMaxRowsDefault);
3605
+ const bulkDirectProfileTimeoutDefault = bulkMode ? Math.min(params.timeoutMs, 6_000) : 0;
3606
+ const bulkDirectProfileTimeoutMs = Number(env.SALESPROMPTER_LINKEDIN_BULK_DIRECT_PROFILE_TIMEOUT_MS ?? bulkDirectProfileTimeoutDefault);
3607
+ return {
3608
+ bulkMode,
3609
+ serperConcurrency: Number.isFinite(serperConcurrency) && serperConcurrency > 0
3610
+ ? Math.trunc(serperConcurrency)
3611
+ : serperConcurrencyDefault,
3612
+ serperMaxQueries: Number.isFinite(serperMaxQueries) && serperMaxQueries > 0
3613
+ ? Math.trunc(serperMaxQueries)
3614
+ : serperMaxQueriesDefault,
3615
+ workflowStageBudgetMs: Number.isFinite(workflowStageBudgetMs) && workflowStageBudgetMs > 0
3616
+ ? Math.trunc(workflowStageBudgetMs)
3617
+ : workflowStageBudgetDefault,
3618
+ serperStageBudgetMs: Number.isFinite(serperStageBudgetMs) && serperStageBudgetMs > 0
3619
+ ? Math.trunc(serperStageBudgetMs)
3620
+ : serperStageBudgetDefault,
3621
+ bulkDirectProfileMaxRows: Number.isFinite(bulkDirectProfileMaxRows) && bulkDirectProfileMaxRows > 0
3622
+ ? Math.trunc(bulkDirectProfileMaxRows)
3623
+ : 0,
3624
+ bulkDirectProfileTimeoutMs: Number.isFinite(bulkDirectProfileTimeoutMs) && bulkDirectProfileTimeoutMs > 0
3625
+ ? Math.trunc(bulkDirectProfileTimeoutMs)
3626
+ : 0
3627
+ };
3628
+ }
3629
+ function shouldAttemptBulkDirectProfileLookup(params) {
3630
+ return (params.strategy.bulkMode &&
3631
+ params.strategy.bulkDirectProfileMaxRows > 0 &&
3632
+ params.strategy.bulkDirectProfileTimeoutMs > 0 &&
3633
+ params.unresolvedRowCount > 0);
3634
+ }
3635
+ function rankContactsForBulkDirectProfileLookup(params) {
3636
+ const scored = params.contacts
3637
+ .filter((contact) => !contact.isVariation)
3638
+ .map((contact) => {
3639
+ const row = params.rowsByContactId.get(contact.contact_id);
3640
+ const normalizedName = normalizeLookupWhitespace(`${contact.firstName} ${contact.lastName}`);
3641
+ const normalizedEmail = normalizeLookupWhitespace(contact.email);
3642
+ const titleKeywords = extractLookupTitleKeywords(contact.jobTitle);
3643
+ const roleKeywords = buildDeepDiveRoleSearchKeywords(contact.deepDiveRecommendedRole);
3644
+ let score = 0;
3645
+ if (row?.linkedinCompanyUrl || contact.linkedinCompanyUrl)
3646
+ score += 80;
3647
+ if (row?.salesNavCompanyUrl)
3648
+ score += 20;
3649
+ if (normalizedEmail && !isSyntheticLinkedInLookupEmail(normalizedEmail))
3650
+ score += 40;
3651
+ if (contact.jobTitle?.trim())
3652
+ score += 25;
3653
+ if (contact.deepDiveRecommendedRole?.trim())
3654
+ score += 15;
3655
+ score += Math.min(20, titleKeywords.length * 5);
3656
+ score += Math.min(15, roleKeywords.length * 5);
3657
+ if (/^contact\s+\d+$/i.test(normalizedName))
3658
+ score -= 100;
3659
+ if (/^(hr|support|facility|buchhaltung|rechnungen)$/i.test(normalizedName))
3660
+ score -= 25;
3661
+ return { contact, score };
3662
+ })
3663
+ .filter((entry) => entry.score > 0)
3664
+ .sort((left, right) => right.score - left.score);
3665
+ return scored.slice(0, params.limit).map((entry) => entry.contact);
3666
+ }
3667
+ async function resolveSerperLinkedInProfilesInParallel(params) {
3668
+ const results = new Map();
3669
+ const contacts = params.contacts;
3670
+ const concurrency = Math.max(1, Math.min(params.concurrency ?? 3, contacts.length || 1));
3671
+ const deadline = params.overallBudgetMs && Number.isFinite(params.overallBudgetMs) && params.overallBudgetMs > 0
3672
+ ? Date.now() + Math.trunc(params.overallBudgetMs)
3673
+ : Number.POSITIVE_INFINITY;
3674
+ let nextIndex = 0;
3675
+ const worker = async () => {
3676
+ while (true) {
3677
+ if (Date.now() >= deadline) {
3678
+ return;
3679
+ }
3680
+ const index = nextIndex++;
3681
+ if (index >= contacts.length) {
3682
+ return;
3683
+ }
3684
+ const contact = contacts[index];
3685
+ const remainingBudget = deadline - Date.now();
3686
+ if (remainingBudget <= 0) {
3687
+ return;
3688
+ }
3689
+ const linkedinUrl = await searchSerperLinkedInProfileUrl(contact, Math.min(params.timeoutMs, remainingBudget), {
3690
+ maxQueries: params.maxQueries
3691
+ });
3692
+ if (linkedinUrl) {
3693
+ results.set(contact.contact_id, linkedinUrl);
3694
+ }
3695
+ }
3696
+ };
3697
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
3698
+ return results;
3699
+ }
3700
+ async function resolveLinkedInCompanyUrlsForContacts(params) {
3701
+ const contacts = params.contacts.filter((contact) => !contact.isVariation && !contact.linkedinCompanyUrl);
3702
+ const uniqueCompanies = new Map();
3703
+ for (const contact of contacts) {
3704
+ const key = normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName);
3705
+ if (!key || uniqueCompanies.has(key)) {
3706
+ continue;
3707
+ }
3708
+ uniqueCompanies.set(key, contact.companyNameOriginal ?? contact.companyName);
3709
+ }
3710
+ const resultsByCompany = new Map();
3711
+ const entries = Array.from(uniqueCompanies.entries());
3712
+ const concurrency = Math.max(1, Math.min(params.concurrency ?? 4, entries.length || 1));
3713
+ const deadline = params.overallBudgetMs && Number.isFinite(params.overallBudgetMs) && params.overallBudgetMs > 0
3714
+ ? Date.now() + Math.trunc(params.overallBudgetMs)
3715
+ : Number.POSITIVE_INFINITY;
3716
+ let nextIndex = 0;
3717
+ const worker = async () => {
3718
+ while (true) {
3719
+ if (Date.now() >= deadline) {
3720
+ return;
3721
+ }
3722
+ const index = nextIndex++;
3723
+ if (index >= entries.length) {
3724
+ return;
3725
+ }
3726
+ const [key, companyName] = entries[index];
3727
+ const remainingBudget = deadline - Date.now();
3728
+ if (remainingBudget <= 0) {
3729
+ return;
3730
+ }
3731
+ const perCompanyTimeout = Math.min(params.timeoutMs, remainingBudget);
3732
+ const linkedinUrl = (await searchSerperLinkedInCompanyUrl(companyName, perCompanyTimeout)) ??
3733
+ (await searchPublicLinkedInCompanyUrl(companyName, perCompanyTimeout));
3734
+ if (linkedinUrl) {
3735
+ resultsByCompany.set(key, linkedinUrl);
3736
+ }
3737
+ }
3738
+ };
3739
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
3740
+ const results = new Map();
3741
+ for (const contact of params.contacts) {
3742
+ const key = normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName);
3743
+ const linkedinUrl = resultsByCompany.get(key);
3744
+ if (linkedinUrl) {
3745
+ results.set(contact.contact_id, linkedinUrl);
3746
+ }
3747
+ }
3748
+ return results;
3749
+ }
1592
3750
  function buildCommandLine(args) {
1593
3751
  return args.map((arg) => shellQuote(arg)).join(" ");
1594
3752
  }
@@ -1644,7 +3802,16 @@ function normalizeLinkedInCompanyHandle(value) {
1644
3802
  }
1645
3803
  try {
1646
3804
  const url = new URL(trimmed);
1647
- if (!/(^|\.)linkedin\.com$/i.test(url.hostname)) {
3805
+ const overrideHostname = (() => {
3806
+ try {
3807
+ const overrideBase = process.env.SALESPROMPTER_LINKEDIN_BASE_URL?.trim();
3808
+ return overrideBase ? new URL(overrideBase).hostname : "";
3809
+ }
3810
+ catch {
3811
+ return "";
3812
+ }
3813
+ })();
3814
+ if (!/(^|\.)linkedin\.com$/i.test(url.hostname) && (!overrideHostname || url.hostname !== overrideHostname)) {
1648
3815
  return null;
1649
3816
  }
1650
3817
  const segments = url.pathname.split("/").filter((segment) => segment.length > 0);
@@ -2244,6 +4411,72 @@ async function fetchWorkspaceLeadSearch(session, requestBody) {
2244
4411
  }
2245
4412
  return WorkspaceLeadSearchResponseSchema.parse(payload).leads;
2246
4413
  }
4414
+ async function buildWorkspaceLeadAccount(icp, target, leads) {
4415
+ const firstLead = leads[0];
4416
+ if (firstLead) {
4417
+ const keywords = Array.from(new Set([target.companyDomain?.split(".")[0], firstLead.industry, firstLead.region, ...icp.keywords].filter((value) => typeof value === "string" && value.trim().length > 0)));
4418
+ return AccountProfileSchema.parse({
4419
+ companyName: target.companyName?.trim() || firstLead.companyName,
4420
+ domain: target.companyDomain?.trim().toLowerCase() || firstLead.domain,
4421
+ industry: firstLead.industry,
4422
+ region: firstLead.region,
4423
+ employeeCount: firstLead.employeeCount,
4424
+ keywords,
4425
+ sources: ["workspace-qualified-leads"]
4426
+ });
4427
+ }
4428
+ return await companyProvider.resolveCompany({
4429
+ companyDomain: target.companyDomain,
4430
+ companyName: target.companyName
4431
+ }, icp);
4432
+ }
4433
+ async function generateLeadsForCommand(options) {
4434
+ const source = z.enum(["auto", "workspace", "fallback"]).parse(options.source ?? "auto");
4435
+ if (source === "fallback") {
4436
+ return await leadProvider.generateLeads(options.icp, options.count, options.target);
4437
+ }
4438
+ if (shouldBypassAuth()) {
4439
+ if (source === "workspace") {
4440
+ throw new Error("workspace lead generation requires authentication. Disable SALESPROMPTER_SKIP_AUTH and log in first.");
4441
+ }
4442
+ return await leadProvider.generateLeads(options.icp, options.count, options.target);
4443
+ }
4444
+ try {
4445
+ const session = await requireAuthSession();
4446
+ const requestBody = options.target.companyDomain || options.target.linkedinCompanyPage
4447
+ ? {
4448
+ mode: "target-company",
4449
+ domain: options.target.companyDomain,
4450
+ linkedinCompanyPage: options.target.linkedinCompanyPage,
4451
+ limit: options.count
4452
+ }
4453
+ : {
4454
+ mode: "reference-company",
4455
+ icp: options.icp,
4456
+ limit: options.count
4457
+ };
4458
+ const leads = await fetchWorkspaceLeadSearch(session, requestBody);
4459
+ const account = await buildWorkspaceLeadAccount(options.icp, options.target, leads);
4460
+ return {
4461
+ provider: "salesprompter-app-workspace-search",
4462
+ mode: "real",
4463
+ account,
4464
+ leads,
4465
+ warnings: []
4466
+ };
4467
+ }
4468
+ catch (error) {
4469
+ if (source === "workspace") {
4470
+ throw error;
4471
+ }
4472
+ const fallback = await leadProvider.generateLeads(options.icp, options.count, options.target);
4473
+ const message = error instanceof Error ? error.message : String(error);
4474
+ return {
4475
+ ...fallback,
4476
+ warnings: [`Workspace lead search unavailable: ${message}`, ...fallback.warnings]
4477
+ };
4478
+ }
4479
+ }
2247
4480
  function buildLinkedInProductsOutputPath(categorySlug) {
2248
4481
  return `./data/linkedin-products-${categorySlug}.json`;
2249
4482
  }
@@ -2856,6 +5089,49 @@ class SalesNavigatorExportRequestError extends Error {
2856
5089
  this.launchDiagnostics = options.launchDiagnostics ?? null;
2857
5090
  }
2858
5091
  }
5092
+ class CliApiRequestError extends Error {
5093
+ statusCode;
5094
+ errorCode;
5095
+ constructor(message, options) {
5096
+ super(message);
5097
+ this.name = "CliApiRequestError";
5098
+ this.statusCode = options.statusCode;
5099
+ this.errorCode = options.errorCode;
5100
+ }
5101
+ }
5102
+ class LinkedInCompanyBackfillBatchError extends Error {
5103
+ failureCode;
5104
+ constructor(message, options) {
5105
+ super(message);
5106
+ this.name = "LinkedInCompanyBackfillBatchError";
5107
+ this.failureCode = options.failureCode;
5108
+ }
5109
+ }
5110
+ function formatLinkedInCompanyBackfillSessionLabel(launch) {
5111
+ const identity = launch.selectedSessionUserEmail?.trim() ||
5112
+ launch.selectedSessionUserHandle?.trim() ||
5113
+ null;
5114
+ const shortHash = launch.selectedSessionCookieSha256?.trim()
5115
+ ? launch.selectedSessionCookieSha256.trim().slice(0, 12)
5116
+ : null;
5117
+ if (identity && shortHash) {
5118
+ return `${identity} (${shortHash})`;
5119
+ }
5120
+ return identity || shortHash || 'the selected LinkedIn session';
5121
+ }
5122
+ function isLinkedInCompanyBackfillInvalidSessionMessage(message) {
5123
+ return /session cookie not valid anymore|expired session cookie|invalid session cookie|can't connect to linkedin with this session cookie|no valid credentials found|please log in to linkedin to get a new one/i.test(message);
5124
+ }
5125
+ function buildLinkedInCompanyBackfillSessionRecoveryMessage(labels) {
5126
+ const uniqueLabels = Array.from(new Set(labels
5127
+ .map((label) => label.trim())
5128
+ .filter((label) => label.length > 0)));
5129
+ if (uniqueLabels.length === 0) {
5130
+ return "Company enrichment exhausted the LinkedIn session pool. Open LinkedIn Sales Navigator in Chrome, reconnect the Salesprompter extension, and retry companies:enrich.";
5131
+ }
5132
+ const attemptedSessions = uniqueLabels.join(", ");
5133
+ return `Company enrichment exhausted the LinkedIn session pool. Phantombuster rejected ${uniqueLabels.length} synced LinkedIn session${uniqueLabels.length === 1 ? "" : "s"} as expired: ${attemptedSessions}. Open LinkedIn Sales Navigator in Chrome, reconnect the Salesprompter extension, and retry companies:enrich.`;
5134
+ }
2859
5135
  const SALES_NAVIGATOR_EXPORT_START_TIMEOUT_MS = 90_000;
2860
5136
  async function withRefreshableAuthSession(session, run, contextLabel = "Salesprompter session expired during crawl. Refreshing login...") {
2861
5137
  let currentSession = session;
@@ -2886,13 +5162,22 @@ async function fetchCliJson(session, request, schema) {
2886
5162
  const text = await response.text();
2887
5163
  const parsed = text.length > 0 ? JSON.parse(text) : {};
2888
5164
  if (!response.ok) {
5165
+ const errorCode = typeof parsed === "object" &&
5166
+ parsed !== null &&
5167
+ "code" in parsed &&
5168
+ typeof parsed.code === "string"
5169
+ ? parsed.code
5170
+ : undefined;
2889
5171
  const errorMessage = typeof parsed === "object" &&
2890
5172
  parsed !== null &&
2891
5173
  "error" in parsed &&
2892
5174
  typeof parsed.error === "string"
2893
5175
  ? parsed.error
2894
5176
  : `request failed (${response.status})`;
2895
- throw new Error(errorMessage);
5177
+ throw new CliApiRequestError(errorMessage, {
5178
+ statusCode: response.status,
5179
+ errorCode
5180
+ });
2896
5181
  }
2897
5182
  return schema.parse(parsed);
2898
5183
  });
@@ -2951,7 +5236,13 @@ async function enrichDirectEmailCompaniesViaApp(session, payload) {
2951
5236
  return value;
2952
5237
  }
2953
5238
  async function fetchLinkedInCompaniesBackfillStatus(session, payload) {
2954
- const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/linkedin-companies/status?clientId=${encodeURIComponent(String(payload.clientId))}&containerId=${encodeURIComponent(payload.containerId)}`, {
5239
+ const url = new URL('/api/cli/linkedin-companies/status', session.apiBaseUrl);
5240
+ url.searchParams.set('clientId', String(payload.clientId));
5241
+ url.searchParams.set('containerId', payload.containerId);
5242
+ if (payload.selectedSessionCookieSha256?.trim()) {
5243
+ url.searchParams.set('selectedSessionCookieSha256', payload.selectedSessionCookieSha256.trim());
5244
+ }
5245
+ const { value } = await fetchCliJson(session, (currentSession) => fetch(url.toString(), {
2955
5246
  method: "GET",
2956
5247
  headers: {
2957
5248
  Authorization: `Bearer ${currentSession.accessToken}`
@@ -2959,6 +5250,17 @@ async function fetchLinkedInCompaniesBackfillStatus(session, payload) {
2959
5250
  }), LinkedInCompanyBackfillStatusResponseSchema);
2960
5251
  return value;
2961
5252
  }
5253
+ async function syncPhantombusterContainersViaApp(session, payload) {
5254
+ const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/phantombuster/containers/sync`, {
5255
+ method: "POST",
5256
+ headers: {
5257
+ "Content-Type": "application/json",
5258
+ Authorization: `Bearer ${currentSession.accessToken}`
5259
+ },
5260
+ body: JSON.stringify(payload)
5261
+ }), PhantombusterContainersSyncResponseSchema);
5262
+ return value;
5263
+ }
2962
5264
  function serializeSalesNavigatorFiltersForApi(filters) {
2963
5265
  return filters.map((filter) => ({
2964
5266
  type: filter.type,
@@ -2985,6 +5287,12 @@ function buildSalesNavigatorSliceRawPayload(slice, extra = {}) {
2985
5287
  resultRetryCount: slice.resultRetryCount ?? null
2986
5288
  };
2987
5289
  }
5290
+ function parseOptionalSalesNavigatorClientId(value) {
5291
+ if (value == null || String(value).trim().length === 0) {
5292
+ return null;
5293
+ }
5294
+ return z.coerce.number().int().positive().parse(value);
5295
+ }
2988
5296
  function buildSalesNavigatorCrawlReportRawPayload(slice, traceId, extra = {}) {
2989
5297
  return buildSalesNavigatorSliceRawPayload({
2990
5298
  sourceQueryUrl: slice.sourceQueryUrl,
@@ -3207,10 +5515,24 @@ async function drainLinkedInCompanyBackfill(session, payload) {
3207
5515
  let startedCompanies = 0;
3208
5516
  let remaining = 0;
3209
5517
  let consecutiveBusyPolls = 0;
5518
+ let consecutiveRetryableFailures = 0;
5519
+ const maxRetryableFailures = 3;
5520
+ let consecutiveInvalidSessionFailures = 0;
5521
+ const maxInvalidSessionFailures = 2;
5522
+ const invalidSessionLabels = [];
5523
+ const excludedSessionCookieSha256 = new Set();
5524
+ const excludedUserEmails = new Set();
5525
+ const excludedUserHandles = new Set();
5526
+ let lastProcessedRemaining = null;
3210
5527
  for (;;) {
3211
5528
  let launched;
3212
5529
  try {
3213
- launched = await launchLinkedInCompaniesBackfill(session, payload);
5530
+ launched = await launchLinkedInCompaniesBackfill(session, {
5531
+ ...payload,
5532
+ excludedSessionCookieSha256: Array.from(excludedSessionCookieSha256),
5533
+ excludedUserEmails: Array.from(excludedUserEmails),
5534
+ excludedUserHandles: Array.from(excludedUserHandles),
5535
+ });
3214
5536
  }
3215
5537
  catch (error) {
3216
5538
  if (isSalesNavigatorAgentBusyError(error)) {
@@ -3221,6 +5543,19 @@ async function drainLinkedInCompanyBackfill(session, payload) {
3221
5543
  await delay(30_000);
3222
5544
  continue;
3223
5545
  }
5546
+ if (isRecoverableLinkedInCompanyBackfillSessionFailure(error) &&
5547
+ consecutiveInvalidSessionFailures < maxInvalidSessionFailures) {
5548
+ consecutiveInvalidSessionFailures += 1;
5549
+ writeProgress(`Company enrichment session expired. Trying another synced LinkedIn session (${consecutiveInvalidSessionFailures}/${maxInvalidSessionFailures})...`);
5550
+ await delay(5_000);
5551
+ continue;
5552
+ }
5553
+ if (isRecoverableLinkedInCompanyBackfillSessionFailure(error)) {
5554
+ throw new Error(buildLinkedInCompanyBackfillSessionRecoveryMessage(invalidSessionLabels));
5555
+ }
5556
+ if (isCompanyBackfillSourceInvalidError(error)) {
5557
+ throw new Error("Company enrichment source is broken. Refresh leadPool_inner_merged and recreate leadPool_new, then retry.");
5558
+ }
3224
5559
  throw error;
3225
5560
  }
3226
5561
  consecutiveBusyPolls = 0;
@@ -3233,24 +5568,178 @@ async function drainLinkedInCompanyBackfill(session, payload) {
3233
5568
  };
3234
5569
  }
3235
5570
  batches += 1;
3236
- startedCompanies += launched.candidates.length;
3237
- writeProgress(`Started company enrichment batch ${batches} for ${launched.candidates.length} companies.`);
5571
+ const launchedCompanies = launched.candidates.length;
5572
+ startedCompanies += launchedCompanies;
5573
+ let initialStatus;
5574
+ try {
5575
+ initialStatus = await waitForLinkedInCompanyBackfillStart(session, {
5576
+ clientId: payload.clientId,
5577
+ containerId: launched.containerId,
5578
+ selectedSessionCookieSha256: launched.selectedSessionCookieSha256 ?? null,
5579
+ });
5580
+ }
5581
+ catch (error) {
5582
+ if (isRetryableLinkedInCompanyBackfillFailure(error) && consecutiveRetryableFailures < maxRetryableFailures) {
5583
+ consecutiveRetryableFailures += 1;
5584
+ batches -= 1;
5585
+ startedCompanies -= launchedCompanies;
5586
+ writeProgress(`Company enrichment batch failed before start (${error.message}). Retrying automatically (${consecutiveRetryableFailures}/${maxRetryableFailures})...`);
5587
+ await delay(5_000);
5588
+ continue;
5589
+ }
5590
+ throw error;
5591
+ }
5592
+ writeProgress(initialStatus.processed
5593
+ ? `Finished company enrichment batch ${batches} for ${launchedCompanies} companies.`
5594
+ : `Started company enrichment batch ${batches} for ${launchedCompanies} companies.`);
5595
+ const batchStartedAt = Date.now();
5596
+ let lastRunningHeartbeatAt = batchStartedAt;
5597
+ let lastPendingPersistenceHeartbeatAt = batchStartedAt;
3238
5598
  for (;;) {
3239
5599
  const status = await fetchLinkedInCompaniesBackfillStatus(session, {
3240
5600
  clientId: payload.clientId,
3241
- containerId: launched.containerId
5601
+ containerId: launched.containerId,
5602
+ selectedSessionCookieSha256: launched.selectedSessionCookieSha256 ?? null,
3242
5603
  });
3243
5604
  remaining = status.remaining;
5605
+ if (!status.running && status.failed) {
5606
+ const batchError = new LinkedInCompanyBackfillBatchError(status.failureMessage ?? "Company enrichment batch failed.", { failureCode: status.failureCode ?? undefined });
5607
+ if (isRetryableLinkedInCompanyBackfillFailure(batchError) &&
5608
+ consecutiveRetryableFailures < maxRetryableFailures) {
5609
+ consecutiveRetryableFailures += 1;
5610
+ batches -= 1;
5611
+ startedCompanies -= launchedCompanies;
5612
+ writeProgress(`Company enrichment batch failed (${batchError.message}). Retrying automatically (${consecutiveRetryableFailures}/${maxRetryableFailures})...`);
5613
+ await delay(5_000);
5614
+ break;
5615
+ }
5616
+ if (isRecoverableLinkedInCompanyBackfillSessionFailure(batchError) &&
5617
+ consecutiveInvalidSessionFailures < maxInvalidSessionFailures) {
5618
+ consecutiveInvalidSessionFailures += 1;
5619
+ invalidSessionLabels.push(formatLinkedInCompanyBackfillSessionLabel(launched));
5620
+ if (launched.selectedSessionCookieSha256?.trim()) {
5621
+ excludedSessionCookieSha256.add(launched.selectedSessionCookieSha256.trim());
5622
+ }
5623
+ if (launched.selectedSessionUserEmail?.trim()) {
5624
+ excludedUserEmails.add(launched.selectedSessionUserEmail.trim());
5625
+ }
5626
+ if (launched.selectedSessionUserHandle?.trim()) {
5627
+ excludedUserHandles.add(launched.selectedSessionUserHandle.trim());
5628
+ }
5629
+ batches -= 1;
5630
+ startedCompanies -= launchedCompanies;
5631
+ writeProgress(`Company enrichment rejected ${formatLinkedInCompanyBackfillSessionLabel(launched)} as expired. Trying another synced LinkedIn session (${consecutiveInvalidSessionFailures}/${maxInvalidSessionFailures})...`);
5632
+ await delay(5_000);
5633
+ break;
5634
+ }
5635
+ if (isRecoverableLinkedInCompanyBackfillSessionFailure(batchError)) {
5636
+ invalidSessionLabels.push(formatLinkedInCompanyBackfillSessionLabel(launched));
5637
+ if (launched.selectedSessionCookieSha256?.trim()) {
5638
+ excludedSessionCookieSha256.add(launched.selectedSessionCookieSha256.trim());
5639
+ }
5640
+ if (launched.selectedSessionUserEmail?.trim()) {
5641
+ excludedUserEmails.add(launched.selectedSessionUserEmail.trim());
5642
+ }
5643
+ if (launched.selectedSessionUserHandle?.trim()) {
5644
+ excludedUserHandles.add(launched.selectedSessionUserHandle.trim());
5645
+ }
5646
+ throw new Error(buildLinkedInCompanyBackfillSessionRecoveryMessage(invalidSessionLabels));
5647
+ }
5648
+ throw batchError;
5649
+ }
3244
5650
  if (!status.running && status.processed) {
5651
+ if (lastProcessedRemaining !== null && status.remaining >= lastProcessedRemaining) {
5652
+ const settledStatus = await waitForLinkedInCompanyBackfillRemainingDrop(session, {
5653
+ clientId: payload.clientId,
5654
+ containerId: launched.containerId,
5655
+ selectedSessionCookieSha256: launched.selectedSessionCookieSha256 ?? null,
5656
+ previousRemaining: lastProcessedRemaining
5657
+ });
5658
+ remaining = settledStatus.remaining;
5659
+ if (remaining >= lastProcessedRemaining) {
5660
+ throw new Error(`Company enrichment batch ${batches} finished but remaining stayed at ${remaining}. Stopping to avoid duplicate launches.`);
5661
+ }
5662
+ }
5663
+ consecutiveRetryableFailures = 0;
5664
+ consecutiveInvalidSessionFailures = 0;
5665
+ lastProcessedRemaining = remaining;
5666
+ const completionMessage = `Finished company enrichment batch ${batches} for ${launchedCompanies} companies.`;
3245
5667
  writeProgress(remaining > 0
3246
- ? `${remaining} companies still waiting. Starting the next batch...`
3247
- : "Company enrichment finished.");
5668
+ ? `${completionMessage} ${remaining} companies still waiting. Starting the next batch...`
5669
+ : `${completionMessage} Company enrichment finished.`);
3248
5670
  break;
3249
5671
  }
5672
+ if (status.running) {
5673
+ const now = Date.now();
5674
+ if (now - lastRunningHeartbeatAt >= 30_000) {
5675
+ const elapsedSeconds = Math.max(1, Math.round((now - batchStartedAt) / 1000));
5676
+ writeProgress(`Company enrichment batch ${batches} is still running (${elapsedSeconds}s elapsed)...`);
5677
+ lastRunningHeartbeatAt = now;
5678
+ }
5679
+ }
5680
+ else if (!status.processed) {
5681
+ const now = Date.now();
5682
+ if (now - lastPendingPersistenceHeartbeatAt >= 30_000) {
5683
+ writeProgress(`Company enrichment batch ${batches} finished remotely. Waiting for results to sync...`);
5684
+ lastPendingPersistenceHeartbeatAt = now;
5685
+ }
5686
+ }
3250
5687
  await delay(15_000);
3251
5688
  }
3252
5689
  }
3253
5690
  }
5691
+ async function waitForLinkedInCompanyBackfillStart(session, payload) {
5692
+ const deadline = Date.now() + 45_000;
5693
+ for (;;) {
5694
+ const status = await fetchLinkedInCompaniesBackfillStatus(session, payload);
5695
+ if (status.failed) {
5696
+ throw new LinkedInCompanyBackfillBatchError(status.failureMessage ?? "Company enrichment batch failed.", { failureCode: status.failureCode ?? undefined });
5697
+ }
5698
+ if (status.running || status.processed) {
5699
+ return status;
5700
+ }
5701
+ if (Date.now() >= deadline) {
5702
+ return status;
5703
+ }
5704
+ await delay(5_000);
5705
+ }
5706
+ }
5707
+ async function waitForLinkedInCompanyBackfillRemainingDrop(session, payload) {
5708
+ const deadline = Date.now() + 90_000;
5709
+ let latestStatus = await fetchLinkedInCompaniesBackfillStatus(session, payload);
5710
+ let lastHeartbeatAt = Date.now();
5711
+ while (Date.now() < deadline) {
5712
+ if (latestStatus.failed) {
5713
+ throw new LinkedInCompanyBackfillBatchError(latestStatus.failureMessage ?? "Company enrichment batch failed.", { failureCode: latestStatus.failureCode ?? undefined });
5714
+ }
5715
+ if (latestStatus.remaining < payload.previousRemaining) {
5716
+ return latestStatus;
5717
+ }
5718
+ const now = Date.now();
5719
+ if (now - lastHeartbeatAt >= 30_000) {
5720
+ writeProgress(`Company enrichment batch finished. Waiting for backlog to update below ${payload.previousRemaining}...`);
5721
+ lastHeartbeatAt = now;
5722
+ }
5723
+ await delay(10_000);
5724
+ latestStatus = await fetchLinkedInCompaniesBackfillStatus(session, payload);
5725
+ }
5726
+ return latestStatus;
5727
+ }
5728
+ function isRetryableLinkedInCompanyBackfillFailure(error) {
5729
+ return error instanceof LinkedInCompanyBackfillBatchError && error.failureCode === "input_empty";
5730
+ }
5731
+ function isRecoverableLinkedInCompanyBackfillSessionFailure(error) {
5732
+ if (error instanceof LinkedInCompanyBackfillBatchError) {
5733
+ return error.failureCode === "invalid_session" || isLinkedInCompanyBackfillInvalidSessionMessage(error.message);
5734
+ }
5735
+ if (error instanceof CliApiRequestError) {
5736
+ return error.errorCode === "invalid_session" || isLinkedInCompanyBackfillInvalidSessionMessage(error.message);
5737
+ }
5738
+ return false;
5739
+ }
5740
+ function isCompanyBackfillSourceInvalidError(error) {
5741
+ return error instanceof CliApiRequestError && error.errorCode === "company_backfill_source_invalid";
5742
+ }
3254
5743
  function isSalesNavigatorSessionError(error) {
3255
5744
  if (error instanceof SalesNavigatorExportRequestError) {
3256
5745
  if (error.errorCode === "invalid_session") {
@@ -3264,11 +5753,12 @@ function isSalesNavigatorSessionError(error) {
3264
5753
  return /can't connect profile|sales navigator account|upsell|linkedin session invalid|linkedin_rate_limited|too many requests|rate.?limit|invalid session cookie|disconnected by linkedin|linkedin-disconnected-while-using-api|provide a new linkedin session cookie/i.test(message);
3265
5754
  }
3266
5755
  function isSalesNavigatorResultArtifactError(error) {
3267
- if (error instanceof SalesNavigatorExportRequestError && error.errorCode === "phantombuster_result_invalid") {
5756
+ if (error instanceof SalesNavigatorExportRequestError &&
5757
+ ["phantombuster_result_invalid", "partial_result_artifact"].includes(error.errorCode ?? "")) {
3268
5758
  return true;
3269
5759
  }
3270
5760
  const message = error instanceof Error ? error.message : String(error);
3271
- return /page has crashed|no valid sales navigator people rows/i.test(message);
5761
+ return /page has crashed|no valid sales navigator people rows|partial result artifact|returned \d+ valid sales navigator people rows, but \d+ were expected/i.test(message);
3272
5762
  }
3273
5763
  function isSalesNavigatorTransientExportError(error) {
3274
5764
  if (isSalesNavigatorSessionError(error) || isSalesNavigatorResultArtifactError(error)) {
@@ -3359,6 +5849,7 @@ async function runSalesNavigatorCrawlAttempt(session, attempt, options, context)
3359
5849
  crawlSliceId: context?.crawlSliceId,
3360
5850
  rawPayload: buildSalesNavigatorSliceRawPayload(attempt, {
3361
5851
  traceId: context?.traceId ?? null,
5852
+ clientId: context?.clientId ?? null,
3362
5853
  phase: shouldProbe ? "probe" : "full_export",
3363
5854
  requestedProfiles: probeProfiles,
3364
5855
  crawlJobId: context?.crawlJobId ?? null,
@@ -3395,6 +5886,7 @@ async function runSalesNavigatorCrawlAttempt(session, attempt, options, context)
3395
5886
  crawlSliceId: context?.crawlSliceId,
3396
5887
  rawPayload: buildSalesNavigatorSliceRawPayload(attempt, {
3397
5888
  traceId: context?.traceId ?? null,
5889
+ clientId: context?.clientId ?? null,
3398
5890
  phase: "full_export_after_probe",
3399
5891
  requestedProfiles: attempt.numberOfProfiles,
3400
5892
  crawlJobId: context?.crawlJobId ?? null,
@@ -3493,6 +5985,8 @@ const SALES_NAVIGATOR_SPLIT_TRIGGER_RESULTS = 1500;
3493
5985
  const SALES_NAVIGATOR_FILTER_IMPACT_MIN_OBSERVATIONS = 3;
3494
5986
  let salesNavigatorFilterImpactModel = null;
3495
5987
  let salesNavigatorFilterImpactLoaded = false;
5988
+ let linkedInProfileHitCache = null;
5989
+ let linkedInProfileHitCacheLoaded = false;
3496
5990
  function getSalesprompterConfigDir() {
3497
5991
  const override = process.env.SALESPROMPTER_CONFIG_DIR?.trim();
3498
5992
  if (override !== undefined && override.length > 0) {
@@ -3503,6 +5997,76 @@ function getSalesprompterConfigDir() {
3503
5997
  function getSalesNavigatorFilterImpactPath() {
3504
5998
  return path.join(getSalesprompterConfigDir(), "salesnav-filter-impact.json");
3505
5999
  }
6000
+ function getLinkedInProfileHitCachePath() {
6001
+ return path.join(getSalesprompterConfigDir(), "linkedin-profile-hits.json");
6002
+ }
6003
+ function buildLinkedInProfileHitCacheKeys(params) {
6004
+ const keys = new Set();
6005
+ const normalizedName = normalizeLooseMatchText(params.fullName);
6006
+ const normalizedCompany = normalizeLooseMatchText(params.companyName);
6007
+ const normalizedEmail = normalizeLookupWhitespace(params.email);
6008
+ const trustedEmail = normalizedEmail && !isSyntheticLinkedInLookupEmail(normalizedEmail) ? normalizedEmail.toLowerCase() : "";
6009
+ const contactId = normalizeLinkedInLookupField(params.contactId);
6010
+ if (contactId && !/^[1-9]\d?$/.test(contactId)) {
6011
+ keys.add(`contact:${contactId}`);
6012
+ }
6013
+ if (normalizedName && normalizedCompany && trustedEmail) {
6014
+ keys.add(`identity:${normalizedName}|${normalizedCompany}|${trustedEmail}`);
6015
+ }
6016
+ if (normalizedName && normalizedCompany) {
6017
+ keys.add(`identity:${normalizedName}|${normalizedCompany}`);
6018
+ }
6019
+ return Array.from(keys);
6020
+ }
6021
+ async function loadLinkedInProfileHitCache() {
6022
+ if (linkedInProfileHitCacheLoaded) {
6023
+ return linkedInProfileHitCache;
6024
+ }
6025
+ linkedInProfileHitCacheLoaded = true;
6026
+ try {
6027
+ const content = await readFile(getLinkedInProfileHitCachePath(), "utf8");
6028
+ const parsed = JSON.parse(content);
6029
+ if (parsed?.version === 1 && parsed.entries && typeof parsed.entries === "object") {
6030
+ linkedInProfileHitCache = parsed;
6031
+ }
6032
+ }
6033
+ catch {
6034
+ linkedInProfileHitCache = null;
6035
+ }
6036
+ return linkedInProfileHitCache;
6037
+ }
6038
+ async function persistLinkedInProfileHitCache() {
6039
+ if (!linkedInProfileHitCache) {
6040
+ return;
6041
+ }
6042
+ const filePath = getLinkedInProfileHitCachePath();
6043
+ await mkdir(path.dirname(filePath), { recursive: true });
6044
+ await writeFile(filePath, `${JSON.stringify(linkedInProfileHitCache, null, 2)}\n`, "utf8");
6045
+ }
6046
+ function upsertLinkedInProfileHitCacheEntry(params) {
6047
+ if (!params.linkedinUrl && !params.salesNavProfileUrl && !params.linkedinCompanyUrl && !params.salesNavCompanyUrl) {
6048
+ return;
6049
+ }
6050
+ if (!linkedInProfileHitCache) {
6051
+ linkedInProfileHitCache = {
6052
+ version: 1,
6053
+ updatedAt: new Date().toISOString(),
6054
+ entries: {}
6055
+ };
6056
+ }
6057
+ const updatedAt = new Date().toISOString();
6058
+ linkedInProfileHitCache.updatedAt = updatedAt;
6059
+ const entry = {
6060
+ linkedinUrl: params.linkedinUrl,
6061
+ salesNavProfileUrl: params.salesNavProfileUrl,
6062
+ linkedinCompanyUrl: params.linkedinCompanyUrl,
6063
+ salesNavCompanyUrl: params.salesNavCompanyUrl,
6064
+ updatedAt
6065
+ };
6066
+ for (const key of buildLinkedInProfileHitCacheKeys(params)) {
6067
+ linkedInProfileHitCache.entries[key] = entry;
6068
+ }
6069
+ }
3506
6070
  async function loadSalesNavigatorFilterImpactModel() {
3507
6071
  if (salesNavigatorFilterImpactLoaded) {
3508
6072
  return salesNavigatorFilterImpactModel;
@@ -3683,7 +6247,8 @@ async function ensureSalesNavigatorSessionPoolReady(queryUrl, options) {
3683
6247
  status: claimed ? "ok" : "skipped",
3684
6248
  selectedSessionUserEmail: claimed?.userEmail ?? null,
3685
6249
  selectedSessionUserHandle: claimed?.userHandle ?? null,
3686
- selectedSessionCookieSha256: claimed?.sessionCookieSha256 ?? null
6250
+ selectedSessionCookieSha256: claimed?.sessionCookieSha256 ?? null,
6251
+ selectedSessionLastIngestedSource: claimed?.lastIngestedSource ?? null
3687
6252
  });
3688
6253
  return {
3689
6254
  ready: true
@@ -3774,6 +6339,7 @@ async function processSalesNavigatorClaimedCrawlSlice(session, jobId, slice, opt
3774
6339
  }, {
3775
6340
  crawlJobId: jobId,
3776
6341
  crawlSliceId: slice.id,
6342
+ clientId: options.clientId ?? null,
3777
6343
  traceId: options.traceId
3778
6344
  });
3779
6345
  const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, {
@@ -3914,9 +6480,11 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
3914
6480
  let nextSessionPoolRetryAt = 0;
3915
6481
  let lastSessionPoolReadyAt = 0;
3916
6482
  const sessionPoolReadinessCooldownMs = 120_000;
6483
+ let allowRetryClaimBeyondMaxSlices = false;
6484
+ let allowedRetrySliceId = null;
3917
6485
  while (true) {
3918
6486
  while (!noMoreClaimableWork && inFlight.size < parallelExports) {
3919
- if (claimedSlices >= options.maxSlices) {
6487
+ if (claimedSlices >= options.maxSlices && !allowRetryClaimBeyondMaxSlices) {
3920
6488
  break;
3921
6489
  }
3922
6490
  if (inFlight.size === 0) {
@@ -4023,6 +6591,15 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
4023
6591
  break;
4024
6592
  }
4025
6593
  const slice = claimed.value.slice;
6594
+ if (claimedSlices >= options.maxSlices &&
6595
+ allowRetryClaimBeyondMaxSlices &&
6596
+ allowedRetrySliceId &&
6597
+ slice.id !== allowedRetrySliceId) {
6598
+ noMoreClaimableWork = true;
6599
+ break;
6600
+ }
6601
+ allowRetryClaimBeyondMaxSlices = false;
6602
+ allowedRetrySliceId = null;
4026
6603
  idlePollCount = 0;
4027
6604
  activeSlice = slice;
4028
6605
  const isNewSlice = !seenSliceIds.has(slice.id);
@@ -4039,6 +6616,7 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
4039
6616
  agentBusyWaitSeconds: options.agentBusyWaitSeconds,
4040
6617
  agentBusyMaxWaits: options.agentBusyMaxWaits,
4041
6618
  claimedSlices: claimedSliceNumber,
6619
+ clientId: options.clientId ?? null,
4042
6620
  traceId: options.traceId,
4043
6621
  logger: options.logger
4044
6622
  }).then((value) => ({ slot, value })));
@@ -4052,6 +6630,8 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
4052
6630
  job = completed.value.job;
4053
6631
  activeSlice = completed.value.activeSlice;
4054
6632
  lastOutcome = completed.value.lastOutcome;
6633
+ allowRetryClaimBeyondMaxSlices = lastOutcome?.outcome === "retryable_failed";
6634
+ allowedRetrySliceId = lastOutcome?.outcome === "retryable_failed" ? completed.value.activeSlice.id : null;
4055
6635
  if (completed.value.forceSessionPoolRecheck) {
4056
6636
  lastSessionPoolReadyAt = 0;
4057
6637
  nextSessionPoolRetryAt = 0;
@@ -4062,6 +6642,11 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
4062
6642
  currentSession = status.session;
4063
6643
  job = status.value.job;
4064
6644
  }
6645
+ else if (!isSalesNavigatorCrawlJobTerminal(job.status)) {
6646
+ const status = await getSalesNavigatorCrawlStatus(currentSession, jobId, options.traceId);
6647
+ currentSession = status.session;
6648
+ job = status.value.job;
6649
+ }
4065
6650
  await options.logger?.log("salesnav.crawl.job.completed", {
4066
6651
  jobId,
4067
6652
  status: job.status,
@@ -4371,6 +6956,15 @@ function buildCliError(error) {
4371
6956
  };
4372
6957
  }
4373
6958
  const message = error instanceof Error ? error.message : "Unknown error";
6959
+ if (message === "linkedin_session_invalid" ||
6960
+ isLinkedInCompanyBackfillInvalidSessionMessage(message) ||
6961
+ /no eligible linkedin session cookies available for company backfill|company session preflight returned/i.test(message)) {
6962
+ return {
6963
+ status: "error",
6964
+ code: "runtime_error",
6965
+ message: buildLinkedInCompanyBackfillSessionRecoveryMessage([])
6966
+ };
6967
+ }
4374
6968
  if (message.includes("not logged in")) {
4375
6969
  return {
4376
6970
  status: "error",
@@ -4459,6 +7053,7 @@ const domainDecisionArraySchema = z.array(z.object({
4459
7053
  reason: z.enum([
4460
7054
  "linkedin-domain",
4461
7055
  "linkedin-website",
7056
+ "better-company-match",
4462
7057
  "highest-hunter-count",
4463
7058
  "fallback-first-non-null",
4464
7059
  "no-domain"
@@ -4510,15 +7105,129 @@ program.configureHelp({
4510
7105
  return visibleName + (cmd.options.length ? " [options]" : "") + (args ? " " + args : "");
4511
7106
  }
4512
7107
  });
4513
- program.addHelpText("after", `
4514
- LLM operator tips:
4515
- - New here? Create your account at https://salesprompter.ai/sign-up, then run: salesprompter auth:login
4516
- - Prefer non-interactive auth: set SALESPROMPTER_TOKEN (+ optional SALESPROMPTER_API_BASE_URL).
4517
- - Use machine output for tools: add --json.
4518
- - One-shot leads flow: leads:pipeline --icp <path> --domain <company.com> --count <n>.
4519
- - Preview contact enrichment first: contacts:resolve-profiles --in <contacts.tsv> --dry-run.
4520
- - For bigger runs, start with a small sample before processing the full file.
4521
- `);
7108
+ program.addHelpText("after", `
7109
+ LLM operator tips:
7110
+ - Install with: curl -fsSL https://docs.salesprompter.ai/install.sh | bash
7111
+ - First-run setup: salesprompter setup
7112
+ - Run a quick health check: salesprompter doctor
7113
+ - New here? Create your account at https://salesprompter.ai/sign-up, then run: salesprompter auth:login
7114
+ - Prefer non-interactive auth: set SALESPROMPTER_TOKEN (+ optional SALESPROMPTER_API_BASE_URL).
7115
+ - Use machine output for tools: add --json.
7116
+ - One-shot leads flow: leads:pipeline --icp <path> --domain <company.com> --count <n>.
7117
+ - Preview contact enrichment first: contacts:resolve-profiles --in <contacts.tsv> --dry-run.
7118
+ - For bigger runs, start with a small sample before processing the full file.
7119
+ `);
7120
+ program
7121
+ .command("setup")
7122
+ .description("Run the fastest first-run setup path for the Salesprompter CLI.")
7123
+ .option("--api-url <url>", "Salesprompter API base URL, defaults to SALESPROMPTER_API_BASE_URL or salesprompter.ai")
7124
+ .option("--timeout-seconds <number>", "Auth login timeout in seconds when setup needs to sign in", "180")
7125
+ .action(async (options) => {
7126
+ const timeoutSeconds = z.coerce.number().int().min(30).max(1800).parse(options.timeoutSeconds);
7127
+ printOutput({
7128
+ status: "ok",
7129
+ command: "setup",
7130
+ next: [
7131
+ "salesprompter auth:login",
7132
+ "salesprompter auth:whoami --verify",
7133
+ "salesprompter wizard"
7134
+ ],
7135
+ docs: "https://docs.salesprompter.ai/quickstart"
7136
+ });
7137
+ if (process.stdin.isTTY && process.stdout.isTTY && !runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
7138
+ await runWizard({
7139
+ apiUrl: options.apiUrl,
7140
+ timeoutSeconds
7141
+ });
7142
+ }
7143
+ });
7144
+ program
7145
+ .command("doctor")
7146
+ .description("Check local CLI prerequisites, auth state, and optional enrichment setup.")
7147
+ .action(async () => {
7148
+ const nodeMajor = Number(process.versions.node.split(".")[0] ?? "0");
7149
+ const readiness = await resolveLlmAuthReadiness();
7150
+ const hasOpenAiKey = Boolean(process.env.OPENAI_API_KEY || process.env.SALESPROMPTER_OPENAI_API_KEY);
7151
+ const hasLinkedInSession = Boolean(process.env.LINKEDIN_CSRF_TOKEN ||
7152
+ process.env.SALESPROMPTER_LINKEDIN_CSRF_TOKEN ||
7153
+ process.env.LINKEDIN_X_LI_IDENTITY ||
7154
+ process.env.SALESPROMPTER_LINKEDIN_X_LI_IDENTITY ||
7155
+ process.env.LINKEDIN_SALES_NAV_COOKIE ||
7156
+ process.env.SALESPROMPTER_LINKEDIN_SALES_NAV_COOKIE);
7157
+ printOutput({
7158
+ status: "ok",
7159
+ command: "doctor",
7160
+ checks: {
7161
+ node: {
7162
+ ok: nodeMajor >= 20,
7163
+ current: process.versions.node,
7164
+ required: ">=20.0.0"
7165
+ },
7166
+ auth: {
7167
+ ok: readiness.ready,
7168
+ mode: readiness.mode,
7169
+ apiBaseUrl: readiness.apiBaseUrl,
7170
+ reason: readiness.reason ?? null
7171
+ },
7172
+ companyCleaningAi: {
7173
+ ok: hasOpenAiKey,
7174
+ envVarPresent: hasOpenAiKey
7175
+ },
7176
+ linkedInSession: {
7177
+ ok: hasLinkedInSession,
7178
+ envVarPresent: hasLinkedInSession
7179
+ }
7180
+ },
7181
+ recommended: [
7182
+ readiness.ready ? null : "salesprompter auth:login",
7183
+ hasOpenAiKey ? null : "Set OPENAI_API_KEY to enable --company-cleaning ai",
7184
+ hasLinkedInSession ? null : "Set LINKEDIN_CSRF_TOKEN, LINKEDIN_X_LI_IDENTITY, and LINKEDIN_SALES_NAV_COOKIE for direct LinkedIn lookup"
7185
+ ].filter(Boolean)
7186
+ });
7187
+ });
7188
+ program
7189
+ .command("packs:list")
7190
+ .description("Show the product capability packs included in the CLI.")
7191
+ .action(() => {
7192
+ printOutput({
7193
+ status: "ok",
7194
+ packs: cliPacks
7195
+ });
7196
+ });
7197
+ program
7198
+ .command("packs:add")
7199
+ .description("Explain how to unlock a capability pack in the Salesprompter CLI.")
7200
+ .argument("<pack>", "Pack slug, for example contacts, research, discovery, or outreach")
7201
+ .action((pack) => {
7202
+ const normalized = String(pack).trim().toLowerCase();
7203
+ const match = cliPacks.find((entry) => entry.slug === normalized);
7204
+ if (!match) {
7205
+ throw new Error(`Unknown pack "${pack}". Run "salesprompter packs:list" to see the supported packs.`);
7206
+ }
7207
+ printOutput({
7208
+ status: "ok",
7209
+ pack: match.slug,
7210
+ title: match.title,
7211
+ available: true,
7212
+ installStatus: match.installStatus,
7213
+ commands: match.commands,
7214
+ message: `The ${match.title} pack is already included. Start with: salesprompter ${match.commands[0]}`
7215
+ });
7216
+ });
7217
+ program
7218
+ .command("upgrade")
7219
+ .description("Show the recommended upgrade command for the current installation.")
7220
+ .action(() => {
7221
+ printOutput({
7222
+ status: "ok",
7223
+ command: "upgrade",
7224
+ recommended: {
7225
+ npmGlobal: "npm i -g salesprompter-cli@latest",
7226
+ npx: "npx -y salesprompter-cli@latest",
7227
+ docs: "https://docs.salesprompter.ai/quickstart"
7228
+ }
7229
+ });
7230
+ });
4522
7231
  program
4523
7232
  .command("auth:login")
4524
7233
  .description("Authenticate CLI with a Salesprompter app token, or device flow if the app supports it.")
@@ -4628,19 +7337,22 @@ program
4628
7337
  if (rows.length === 0) {
4629
7338
  throw new Error("No contact rows found. Provide TSV/CSV/JSON input via --in or stdin.");
4630
7339
  }
7340
+ let authSession = null;
4631
7341
  let sessionOrgId = "";
4632
7342
  if (!shouldBypassAuth()) {
4633
7343
  try {
4634
- const session = await requireAuthSession();
4635
- sessionOrgId = session.user.orgId ?? "";
7344
+ authSession = await requireAuthSession();
7345
+ sessionOrgId = authSession.user.orgId ?? "";
4636
7346
  }
4637
7347
  catch {
7348
+ authSession = null;
4638
7349
  sessionOrgId = "";
4639
7350
  }
4640
7351
  }
4641
7352
  const companyCleaningMode = resolveCompanyCleaningMode(String(options.companyCleaning ?? process.env.SALESPROMPTER_COMPANY_CLEANING_MODE ?? "basic"));
4642
7353
  const cleanedCompanyMap = await buildCompanyNameCleaningMap(rows, companyCleaningMode);
4643
7354
  const contacts = toLinkedInUrlLookupContacts(rows, cleanedCompanyMap);
7355
+ await loadLinkedInProfileHitCache();
4644
7356
  if (options.dryRun) {
4645
7357
  const payload = {
4646
7358
  status: "ok",
@@ -4656,68 +7368,558 @@ program
4656
7368
  printOutput(payload);
4657
7369
  return;
4658
7370
  }
4659
- const enrichedRows = await resolveLinkedInUrlsFromSalesNavRows({
4660
- rows,
4661
- orgId: String(options.orgId ?? "").trim() || undefined
7371
+ const orgId = String(options.orgId ?? "").trim() || undefined;
7372
+ const strategy = resolveLinkedInBulkStrategyConfig({
7373
+ rowCount: rows.length,
7374
+ timeoutMs
4662
7375
  });
7376
+ const useSalesNavRowPrepass = !strategy.bulkMode &&
7377
+ shouldUseSalesNavRowPrepass({
7378
+ rows,
7379
+ orgId
7380
+ });
7381
+ const enrichedRows = useSalesNavRowPrepass
7382
+ ? await resolveLinkedInUrlsFromSalesNavRows({
7383
+ rows,
7384
+ orgId
7385
+ })
7386
+ : rows.map((row, index) => ({
7387
+ clientId: row.clientId,
7388
+ fullName: row.fullName,
7389
+ companyName: row.companyName,
7390
+ linkedinUrl: null,
7391
+ salesNavProfileUrl: null,
7392
+ linkedinCompanyUrl: row.linkedinCompanyUrl?.trim() || null,
7393
+ salesNavCompanyUrl: null,
7394
+ found: false,
7395
+ companyFound: Boolean(row.linkedinCompanyUrl?.trim()),
7396
+ contactId: normalizeLinkedInLookupField(row.contactId) ?? `${index + 1}`,
7397
+ source: null,
7398
+ companySource: row.linkedinCompanyUrl?.trim() ? "input" : null,
7399
+ matchedFullName: null,
7400
+ matchedCompanyName: null,
7401
+ matchedTitle: null,
7402
+ matchedOrgId: null,
7403
+ matchedCompanyEmployeeCount: null
7404
+ }));
7405
+ const contactById = new Map(contacts.filter((contact) => !contact.isVariation).map((contact) => [contact.contact_id, contact]));
7406
+ for (const row of enrichedRows) {
7407
+ if (row.found) {
7408
+ continue;
7409
+ }
7410
+ const contact = contactById.get(row.contactId);
7411
+ const cacheKeys = buildLinkedInProfileHitCacheKeys({
7412
+ fullName: row.fullName,
7413
+ companyName: row.companyName,
7414
+ email: contact?.email,
7415
+ contactId: row.contactId
7416
+ });
7417
+ const cachedEntry = cacheKeys
7418
+ .map((key) => linkedInProfileHitCache?.entries[key] ?? null)
7419
+ .find(Boolean);
7420
+ if (!cachedEntry) {
7421
+ continue;
7422
+ }
7423
+ row.linkedinUrl = cachedEntry.linkedinUrl ?? row.linkedinUrl ?? null;
7424
+ row.salesNavProfileUrl = cachedEntry.salesNavProfileUrl ?? row.salesNavProfileUrl ?? null;
7425
+ row.linkedinCompanyUrl = cachedEntry.linkedinCompanyUrl ?? row.linkedinCompanyUrl ?? null;
7426
+ row.salesNavCompanyUrl = cachedEntry.salesNavCompanyUrl ?? row.salesNavCompanyUrl ?? null;
7427
+ row.found = Boolean(row.linkedinUrl || row.salesNavProfileUrl);
7428
+ row.companyFound = Boolean(row.linkedinCompanyUrl || row.salesNavCompanyUrl);
7429
+ row.source = row.found ? "cache" : row.source;
7430
+ row.companySource =
7431
+ row.companyFound && !row.companySource ? "cache" : row.companySource;
7432
+ }
4663
7433
  let directAttempted = false;
7434
+ let workflowAttempted = false;
7435
+ const parsedClientIds = Array.from(new Set(rows
7436
+ .map((row) => Number(row.clientId))
7437
+ .filter((value) => Number.isFinite(value) && value > 0)));
7438
+ if (authSession && parsedClientIds.length === 1) {
7439
+ try {
7440
+ const uniqueCompanies = Array.from(new Map(contacts
7441
+ .filter((contact) => !contact.isVariation)
7442
+ .map((contact) => {
7443
+ const key = normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName);
7444
+ return [
7445
+ key,
7446
+ {
7447
+ companyId: contact.contact_id,
7448
+ companyName: contact.companyNameOriginal ?? contact.companyName,
7449
+ companyNameCleaned: cleanedCompanyMap.get(key) ?? normalizeLookupWhitespace(contact.companyNameOriginal ?? contact.companyName)
7450
+ }
7451
+ ];
7452
+ })).values());
7453
+ if (uniqueCompanies.length > 0) {
7454
+ const appCompanyResult = await enrichDirectEmailCompaniesViaApp(authSession, {
7455
+ clientId: parsedClientIds[0],
7456
+ companies: uniqueCompanies
7457
+ });
7458
+ const companyByNormalizedName = new Map(appCompanyResult.companies.map((company) => [
7459
+ normalizeLookupCompanyForCleaning(company.companyName),
7460
+ company.linkedinCompanyPage ?? null
7461
+ ]));
7462
+ for (const row of enrichedRows) {
7463
+ if (row.linkedinCompanyUrl) {
7464
+ continue;
7465
+ }
7466
+ const normalizedName = normalizeLookupCompanyForCleaning(row.companyName);
7467
+ const linkedinCompanyUrl = companyByNormalizedName.get(normalizedName) ?? null;
7468
+ if (!linkedinCompanyUrl) {
7469
+ continue;
7470
+ }
7471
+ row.linkedinCompanyUrl = linkedinCompanyUrl;
7472
+ row.companyFound = true;
7473
+ row.companySource = "workflow";
7474
+ }
7475
+ }
7476
+ }
7477
+ catch (error) {
7478
+ writeProgress(`Skipping app-backed company enrichment: ${error instanceof Error ? error.message : String(error)}`);
7479
+ }
7480
+ }
7481
+ const contactsMissingCompanyUrl = contacts.filter((contact) => !contact.isVariation &&
7482
+ enrichedRows.some((row) => row.contactId === contact.contact_id && !row.linkedinCompanyUrl));
7483
+ if (contactsMissingCompanyUrl.length > 0) {
7484
+ const companyUrlByContactId = await resolveLinkedInCompanyUrlsForContacts({
7485
+ contacts: contactsMissingCompanyUrl,
7486
+ timeoutMs: Math.min(timeoutMs, 15_000),
7487
+ concurrency: strategy.bulkMode ? 6 : 3,
7488
+ overallBudgetMs: strategy.bulkMode ? 20_000 : 10_000
7489
+ });
7490
+ for (const row of enrichedRows) {
7491
+ if (row.linkedinCompanyUrl) {
7492
+ continue;
7493
+ }
7494
+ const linkedinCompanyUrl = companyUrlByContactId.get(row.contactId);
7495
+ if (!linkedinCompanyUrl) {
7496
+ continue;
7497
+ }
7498
+ row.linkedinCompanyUrl = linkedinCompanyUrl;
7499
+ row.companyFound = true;
7500
+ row.companySource = "web-search";
7501
+ }
7502
+ }
4664
7503
  const missingRows = enrichedRows.filter((row) => !row.found);
7504
+ const useDirectPeopleLookup = !strategy.bulkMode &&
7505
+ shouldUseDirectPeopleLookup({
7506
+ rowCount: missingRows.length
7507
+ });
7508
+ const useWorkflowPeopleLookup = !strategy.bulkMode &&
7509
+ shouldUseWorkflowPeopleLookup({
7510
+ rowCount: missingRows.length
7511
+ });
4665
7512
  if (missingRows.length > 0) {
4666
- directAttempted = true;
4667
- const directContacts = contacts.filter((contact) => missingRows.some((row) => row.contactId === contact.contact_id));
4668
- const result = await invokeLinkedInUrlEnrichmentDirect({
4669
- contacts: directContacts,
4670
- timeoutMs
7513
+ const rowByContactId = new Map(enrichedRows.map((row) => [row.contactId, row]));
7514
+ const directContacts = contacts
7515
+ .filter((contact) => missingRows.some((row) => row.contactId === contact.contact_id))
7516
+ .map((contact) => {
7517
+ const row = rowByContactId.get(contact.contact_id);
7518
+ if (!row) {
7519
+ return contact;
7520
+ }
7521
+ return {
7522
+ ...contact,
7523
+ linkedinCompanyUrl: row.linkedinCompanyUrl ?? contact.linkedinCompanyUrl,
7524
+ companyNameOriginal: row.matchedCompanyName ?? contact.companyNameOriginal,
7525
+ companyName: row.matchedCompanyName && normalizeLookupCompanyForSearch(row.matchedCompanyName)
7526
+ ? normalizeLookupCompanyForSearch(row.matchedCompanyName)
7527
+ : contact.companyName
7528
+ };
7529
+ });
7530
+ let linkedInUrlByContactId = new Map();
7531
+ if (useDirectPeopleLookup) {
7532
+ try {
7533
+ directAttempted = true;
7534
+ const result = await invokeLinkedInUrlEnrichmentDirect({
7535
+ contacts: directContacts,
7536
+ timeoutMs
7537
+ });
7538
+ const directCompanyContextByKey = new Map((result.companyContexts ?? []).map((context) => [context.normalizedCompanyKey, context]));
7539
+ linkedInUrlByContactId = new Map(result.contacts.map((contact) => [
7540
+ contact.contact_id,
7541
+ {
7542
+ linkedinUrl: contact.linkedin_url ?? null,
7543
+ salesNavProfileUrl: contact.sales_nav_profile_url ?? null,
7544
+ linkedinCompanyUrl: null,
7545
+ salesNavCompanyUrl: null,
7546
+ matchedFullName: contact.matched_full_name ?? null,
7547
+ matchedCompanyName: contact.matched_company_name ?? null,
7548
+ matchedTitle: contact.matched_title ?? null
7549
+ }
7550
+ ]));
7551
+ for (const row of enrichedRows) {
7552
+ if (row.found)
7553
+ continue;
7554
+ const profile = linkedInUrlByContactId.get(row.contactId);
7555
+ if (profile?.linkedinUrl) {
7556
+ row.linkedinUrl = profile.linkedinUrl;
7557
+ row.salesNavProfileUrl = profile.salesNavProfileUrl ?? row.salesNavProfileUrl ?? null;
7558
+ row.found = true;
7559
+ row.source = "linkedin-direct";
7560
+ row.matchedFullName = profile.matchedFullName ?? row.matchedFullName ?? null;
7561
+ row.matchedCompanyName = profile.matchedCompanyName ?? row.matchedCompanyName ?? null;
7562
+ row.matchedTitle = profile.matchedTitle ?? row.matchedTitle ?? null;
7563
+ }
7564
+ const directContact = directContacts.find((candidate) => candidate.contact_id === row.contactId && !candidate.isVariation);
7565
+ const companyContext = directContact
7566
+ ? directCompanyContextByKey.get(buildDirectCompanyContextKey(directContact))
7567
+ : null;
7568
+ if (companyContext && !row.linkedinCompanyUrl) {
7569
+ row.linkedinCompanyUrl = companyContext.linkedinCompanyUrl ?? row.linkedinCompanyUrl ?? null;
7570
+ row.salesNavCompanyUrl = companyContext.salesNavCompanyUrl ?? row.salesNavCompanyUrl ?? null;
7571
+ row.companyFound = Boolean(row.linkedinCompanyUrl || row.salesNavCompanyUrl);
7572
+ row.companySource =
7573
+ row.linkedinCompanyUrl || row.salesNavCompanyUrl ? "linkedin-direct" : row.companySource ?? null;
7574
+ row.matchedCompanyName = companyContext.matchedCompanyName ?? row.matchedCompanyName ?? null;
7575
+ row.matchedCompanyEmployeeCount =
7576
+ companyContext.matchedCompanyEmployeeCount ?? row.matchedCompanyEmployeeCount ?? null;
7577
+ }
7578
+ }
7579
+ const contactsStillMissingCompany = contacts.filter((contact) => !contact.isVariation &&
7580
+ enrichedRows.some((row) => row.contactId === contact.contact_id && !row.linkedinCompanyUrl && !row.salesNavCompanyUrl));
7581
+ if (contactsStillMissingCompany.length > 0) {
7582
+ const companyResult = await invokeLinkedInCompanyEnrichmentDirect({
7583
+ contacts: contactsStillMissingCompany,
7584
+ timeoutMs,
7585
+ precomputedContexts: result.companyContexts
7586
+ });
7587
+ const companyByContactId = new Map(companyResult.contacts.map((contact) => [
7588
+ contact.contact_id,
7589
+ {
7590
+ linkedinCompanyUrl: contact.linkedin_company_url ?? null,
7591
+ salesNavCompanyUrl: contact.sales_nav_company_url ?? null,
7592
+ matchedCompanyName: contact.matched_company_name ?? null,
7593
+ matchedCompanyEmployeeCount: contact.matched_company_employee_count ?? null
7594
+ }
7595
+ ]));
7596
+ for (const row of enrichedRows) {
7597
+ const company = companyByContactId.get(row.contactId);
7598
+ if (!company || row.linkedinCompanyUrl) {
7599
+ continue;
7600
+ }
7601
+ row.linkedinCompanyUrl = company.linkedinCompanyUrl;
7602
+ row.salesNavCompanyUrl = company.salesNavCompanyUrl ?? row.salesNavCompanyUrl ?? null;
7603
+ row.companyFound = Boolean(company.linkedinCompanyUrl || company.salesNavCompanyUrl);
7604
+ row.companySource =
7605
+ company.linkedinCompanyUrl || company.salesNavCompanyUrl ? "linkedin-direct" : row.companySource ?? null;
7606
+ row.matchedCompanyName = company.matchedCompanyName ?? row.matchedCompanyName ?? null;
7607
+ row.matchedCompanyEmployeeCount =
7608
+ company.matchedCompanyEmployeeCount ?? row.matchedCompanyEmployeeCount ?? null;
7609
+ }
7610
+ }
7611
+ }
7612
+ catch (error) {
7613
+ const message = error instanceof Error ? error.message : String(error);
7614
+ if (!/Missing LinkedIn direct lookup session/i.test(message)) {
7615
+ throw error;
7616
+ }
7617
+ }
7618
+ }
7619
+ const stillMissingAfterDirect = enrichedRows.filter((row) => !row.found);
7620
+ const contactsStillMissing = directContacts.filter((contact) => stillMissingAfterDirect.some((row) => row.contactId === contact.contact_id));
7621
+ if (contactsStillMissing.length > 0 && useWorkflowPeopleLookup) {
7622
+ workflowAttempted = true;
7623
+ try {
7624
+ const workflow = await invokeLinkedInUrlEnrichmentWorkflow({
7625
+ contacts: contactsStillMissing,
7626
+ externalUserId: orgId || sessionOrgId || "cli_direct_lookup",
7627
+ timeoutMs: Math.min(timeoutMs, strategy.workflowStageBudgetMs)
7628
+ });
7629
+ if (!workflow.response.ok) {
7630
+ throw new Error(`LinkedIn enrichment workflow returned ${workflow.response.status}: ${workflow.bodyText.slice(0, 300)}`);
7631
+ }
7632
+ linkedInUrlByContactId = normalizeWorkflowLinkedInUrlResult({
7633
+ parsedBody: workflow.parsedBody,
7634
+ contacts: contactsStillMissing
7635
+ });
7636
+ for (const row of enrichedRows) {
7637
+ if (row.found)
7638
+ continue;
7639
+ const profile = linkedInUrlByContactId.get(row.contactId);
7640
+ if (profile?.linkedinUrl) {
7641
+ row.linkedinUrl = profile.linkedinUrl;
7642
+ row.salesNavProfileUrl = profile.salesNavProfileUrl ?? row.salesNavProfileUrl ?? null;
7643
+ row.linkedinCompanyUrl = profile.linkedinCompanyUrl ?? row.linkedinCompanyUrl ?? null;
7644
+ row.salesNavCompanyUrl = profile.salesNavCompanyUrl ?? row.salesNavCompanyUrl ?? null;
7645
+ row.found = true;
7646
+ row.companyFound = Boolean(row.linkedinCompanyUrl || row.salesNavCompanyUrl);
7647
+ row.source = "workflow";
7648
+ row.companySource =
7649
+ row.linkedinCompanyUrl || row.salesNavCompanyUrl ? "workflow" : row.companySource ?? null;
7650
+ }
7651
+ }
7652
+ }
7653
+ catch (error) {
7654
+ writeProgress(`Skipping workflow profile enrichment: ${error instanceof Error ? error.message : String(error)}`);
7655
+ }
7656
+ }
7657
+ const serperContacts = directContacts.filter((contact) => enrichedRows.some((row) => row.contactId === contact.contact_id && !row.found));
7658
+ if (strategy.bulkMode && serperContacts.length > 0) {
7659
+ writeProgress(`Using bulk profile resolution strategy for ${serperContacts.length} remaining contacts.`);
7660
+ }
7661
+ const serperResults = await resolveSerperLinkedInProfilesInParallel({
7662
+ contacts: serperContacts.filter((contact) => !contact.isVariation),
7663
+ timeoutMs,
7664
+ concurrency: Math.min(strategy.serperConcurrency, serperContacts.length || 1),
7665
+ maxQueries: strategy.serperMaxQueries,
7666
+ overallBudgetMs: strategy.serperStageBudgetMs
4671
7667
  });
4672
- const linkedInUrlByContactId = new Map(result.contacts.map((contact) => [contact.contact_id, contact.linkedin_url]));
4673
7668
  for (const row of enrichedRows) {
4674
7669
  if (row.found)
4675
7670
  continue;
4676
- const linkedinUrl = linkedInUrlByContactId.get(row.contactId) ?? null;
4677
- if (linkedinUrl) {
4678
- row.linkedinUrl = linkedinUrl;
4679
- row.found = true;
4680
- row.source = "linkedin-direct";
7671
+ const linkedinUrl = serperResults.get(row.contactId);
7672
+ if (!linkedinUrl)
7673
+ continue;
7674
+ row.linkedinUrl = linkedinUrl;
7675
+ row.found = true;
7676
+ row.source = "web-search";
7677
+ }
7678
+ const stillMissingAfterSerper = enrichedRows.filter((row) => !row.found);
7679
+ if (shouldAttemptBulkDirectProfileLookup({
7680
+ strategy,
7681
+ unresolvedRowCount: stillMissingAfterSerper.length
7682
+ })) {
7683
+ const bulkDirectCandidates = rankContactsForBulkDirectProfileLookup({
7684
+ contacts: directContacts.filter((contact) => stillMissingAfterSerper.some((row) => row.contactId === contact.contact_id)),
7685
+ rowsByContactId: rowByContactId,
7686
+ limit: strategy.bulkDirectProfileMaxRows
7687
+ });
7688
+ if (bulkDirectCandidates.length > 0) {
7689
+ writeProgress(`Using bulk direct profile follow-up for ${bulkDirectCandidates.length} high-signal unresolved contacts.`);
7690
+ try {
7691
+ directAttempted = true;
7692
+ const result = await invokeLinkedInUrlEnrichmentDirect({
7693
+ contacts: bulkDirectCandidates,
7694
+ timeoutMs: strategy.bulkDirectProfileTimeoutMs,
7695
+ perAttemptTimeoutMs: Math.min(strategy.bulkDirectProfileTimeoutMs, 2_500),
7696
+ perContactBudgetMs: strategy.bulkDirectProfileTimeoutMs
7697
+ });
7698
+ const directCompanyContextByKey = new Map((result.companyContexts ?? []).map((context) => [context.normalizedCompanyKey, context]));
7699
+ const bulkDirectByContactId = new Map(result.contacts.map((contact) => [
7700
+ contact.contact_id,
7701
+ {
7702
+ linkedinUrl: contact.linkedin_url ?? null,
7703
+ salesNavProfileUrl: contact.sales_nav_profile_url ?? null
7704
+ }
7705
+ ]));
7706
+ for (const row of enrichedRows) {
7707
+ if (row.found)
7708
+ continue;
7709
+ const profile = bulkDirectByContactId.get(row.contactId);
7710
+ if (profile?.linkedinUrl) {
7711
+ row.linkedinUrl = profile.linkedinUrl;
7712
+ row.salesNavProfileUrl = profile.salesNavProfileUrl ?? row.salesNavProfileUrl ?? null;
7713
+ row.found = true;
7714
+ row.source = "linkedin-direct";
7715
+ }
7716
+ const directContact = bulkDirectCandidates.find((candidate) => candidate.contact_id === row.contactId && !candidate.isVariation);
7717
+ const companyContext = directContact
7718
+ ? directCompanyContextByKey.get(buildDirectCompanyContextKey(directContact))
7719
+ : null;
7720
+ if (companyContext && !row.linkedinCompanyUrl) {
7721
+ row.linkedinCompanyUrl = companyContext.linkedinCompanyUrl ?? row.linkedinCompanyUrl ?? null;
7722
+ row.salesNavCompanyUrl = companyContext.salesNavCompanyUrl ?? row.salesNavCompanyUrl ?? null;
7723
+ row.companyFound = Boolean(row.linkedinCompanyUrl || row.salesNavCompanyUrl);
7724
+ row.companySource =
7725
+ row.linkedinCompanyUrl || row.salesNavCompanyUrl ? "linkedin-direct" : row.companySource ?? null;
7726
+ row.matchedCompanyName = companyContext.matchedCompanyName ?? row.matchedCompanyName ?? null;
7727
+ row.matchedCompanyEmployeeCount =
7728
+ companyContext.matchedCompanyEmployeeCount ?? row.matchedCompanyEmployeeCount ?? null;
7729
+ }
7730
+ }
7731
+ }
7732
+ catch (error) {
7733
+ const message = error instanceof Error ? error.message : String(error);
7734
+ if (!/Missing LinkedIn direct lookup session/i.test(message)) {
7735
+ writeProgress(`Skipping bulk direct profile follow-up: ${message}`);
7736
+ }
7737
+ }
7738
+ }
7739
+ }
7740
+ }
7741
+ const payload = {
7742
+ status: "ok",
7743
+ orgId: String(options.orgId ?? "").trim() || null,
7744
+ requested: rows.length,
7745
+ found: enrichedRows.filter((row) => row.found).length,
7746
+ companiesFound: enrichedRows.filter((row) => row.companyFound).length,
7747
+ directAttempted,
7748
+ workflowAttempted,
7749
+ bulkMode: strategy.bulkMode,
7750
+ rows: enrichedRows
7751
+ };
7752
+ for (const row of enrichedRows) {
7753
+ const contact = contactById.get(row.contactId);
7754
+ upsertLinkedInProfileHitCacheEntry({
7755
+ fullName: row.fullName,
7756
+ companyName: row.companyName,
7757
+ email: contact?.email,
7758
+ contactId: row.contactId,
7759
+ linkedinUrl: row.linkedinUrl ?? null,
7760
+ salesNavProfileUrl: row.salesNavProfileUrl ?? null,
7761
+ linkedinCompanyUrl: row.linkedinCompanyUrl ?? null,
7762
+ salesNavCompanyUrl: row.salesNavCompanyUrl ?? null
7763
+ });
7764
+ }
7765
+ await persistLinkedInProfileHitCache();
7766
+ if (options.out) {
7767
+ await writeJsonFile(options.out, payload);
7768
+ }
7769
+ printOutput(payload);
7770
+ });
7771
+ program
7772
+ .command("companies:find-linkedin-urls")
7773
+ .alias("companies:resolve-linkedin-urls")
7774
+ .description("Resolve LinkedIn company URLs from a pasted company list directly in the CLI.")
7775
+ .option("--in <path>", "Input TSV/CSV/JSON file path. Omit to read from stdin.")
7776
+ .option("--out <path>", "Optional output JSON path for the enriched rows.")
7777
+ .option("--client-id <id>", "Optional clientId override for app-backed enrichment.")
7778
+ .option("--timeout-ms <number>", "Lookup timeout in milliseconds", "30000")
7779
+ .option("--company-cleaning <mode>", "Company cleaning mode: off, basic, or ai", "basic")
7780
+ .option("--dry-run", "Preview the normalized payload without calling LinkedIn", false)
7781
+ .action(async (options) => {
7782
+ const timeoutMs = z.coerce.number().int().min(1000).max(300000).parse(options.timeoutMs);
7783
+ const inputContent = options.in ? await readFile(options.in, "utf8") : await readAllStdin();
7784
+ const rows = parseLinkedInCompanyLookupInput(inputContent);
7785
+ if (rows.length === 0) {
7786
+ throw new Error("No company rows found. Provide TSV/CSV/JSON input via --in or stdin.");
7787
+ }
7788
+ let authSession = null;
7789
+ if (!shouldBypassAuth()) {
7790
+ authSession = await requireAuthSession().catch(() => null);
7791
+ }
7792
+ const companyCleaningMode = resolveCompanyCleaningMode(String(options.companyCleaning ?? process.env.SALESPROMPTER_COMPANY_CLEANING_MODE ?? "basic"));
7793
+ const lookupRows = rows.map((row) => ({
7794
+ clientId: row.clientId,
7795
+ fullName: "",
7796
+ companyName: row.companyName
7797
+ }));
7798
+ const cleanedCompanyMap = await buildCompanyNameCleaningMap(lookupRows, companyCleaningMode);
7799
+ const contacts = toLinkedInUrlLookupContacts(lookupRows, cleanedCompanyMap);
7800
+ if (options.dryRun) {
7801
+ const payload = {
7802
+ status: "ok",
7803
+ dryRun: true,
7804
+ companyCleaningMode,
7805
+ companies: contacts.length,
7806
+ sample: contacts.slice(0, 5).map((contact) => ({
7807
+ companyId: contact.contact_id,
7808
+ companyName: contact.companyNameOriginal ?? contact.companyName,
7809
+ companyNameCleaned: contact.companyName
7810
+ }))
7811
+ };
7812
+ if (options.out) {
7813
+ await writeJsonFile(options.out, payload);
7814
+ }
7815
+ printOutput(payload);
7816
+ return;
7817
+ }
7818
+ const clientId = resolveDirectEmailEnrichmentClientId(rows.map((row) => ({
7819
+ clientId: row.clientId,
7820
+ companyName: row.companyName,
7821
+ fullName: ""
7822
+ })), options.clientId);
7823
+ const results = contacts
7824
+ .filter((contact) => !contact.isVariation)
7825
+ .map((contact) => ({
7826
+ clientId: String(clientId),
7827
+ companyName: contact.companyNameOriginal ?? contact.companyName,
7828
+ linkedinCompanyUrl: null,
7829
+ salesNavCompanyUrl: null,
7830
+ domain: null,
7831
+ found: false,
7832
+ source: null,
7833
+ matchedCompanyName: null,
7834
+ matchedCompanyEmployeeCount: null
7835
+ }));
7836
+ const resultByNormalizedName = new Map(results.map((row) => [normalizeLookupCompanyForCleaning(row.companyName), row]));
7837
+ if (authSession) {
7838
+ try {
7839
+ const uniqueCompanies = contacts
7840
+ .filter((contact) => !contact.isVariation)
7841
+ .map((contact) => ({
7842
+ companyId: contact.contact_id,
7843
+ companyName: contact.companyNameOriginal ?? contact.companyName,
7844
+ companyNameCleaned: cleanedCompanyMap.get(normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName)) ?? normalizeLookupWhitespace(contact.companyNameOriginal ?? contact.companyName)
7845
+ }));
7846
+ if (uniqueCompanies.length > 0) {
7847
+ const enrichedCompanies = await enrichDirectEmailCompaniesViaApp(authSession, {
7848
+ clientId,
7849
+ companies: uniqueCompanies
7850
+ });
7851
+ for (const company of enrichedCompanies.companies) {
7852
+ const row = resultByNormalizedName.get(normalizeLookupCompanyForCleaning(company.companyName));
7853
+ if (!row) {
7854
+ continue;
7855
+ }
7856
+ row.domain = company.domain ?? row.domain ?? null;
7857
+ row.linkedinCompanyUrl = company.linkedinCompanyPage ?? row.linkedinCompanyUrl ?? null;
7858
+ row.found = Boolean(row.linkedinCompanyUrl || row.salesNavCompanyUrl);
7859
+ row.source = row.linkedinCompanyUrl ? "app" : row.source;
7860
+ }
4681
7861
  }
4682
7862
  }
7863
+ catch {
7864
+ // Ignore app failures here and keep falling back to direct or public lookup.
7865
+ }
4683
7866
  }
4684
7867
  try {
4685
7868
  const companyResult = await invokeLinkedInCompanyEnrichmentDirect({
4686
7869
  contacts,
4687
7870
  timeoutMs
4688
7871
  });
4689
- const companyByContactId = new Map(companyResult.contacts.map((contact) => [
4690
- contact.contact_id,
4691
- {
4692
- linkedinCompanyUrl: contact.linkedin_company_url ?? null,
4693
- matchedCompanyName: contact.matched_company_name ?? null,
4694
- matchedCompanyEmployeeCount: contact.matched_company_employee_count ?? null
7872
+ const companyByContactId = new Map(companyResult.contacts.map((contact) => [contact.contact_id, contact]));
7873
+ for (const contact of contacts) {
7874
+ if (contact.isVariation) {
7875
+ continue;
4695
7876
  }
4696
- ]));
4697
- for (const row of enrichedRows) {
4698
- const company = companyByContactId.get(row.contactId);
4699
- if (!company || row.linkedinCompanyUrl) {
7877
+ const row = resultByNormalizedName.get(normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName));
7878
+ const company = companyByContactId.get(contact.contact_id);
7879
+ if (!row || !company) {
4700
7880
  continue;
4701
7881
  }
4702
- row.linkedinCompanyUrl = company.linkedinCompanyUrl;
4703
- row.companyFound = Boolean(company.linkedinCompanyUrl);
4704
- row.companySource = company.linkedinCompanyUrl ? "linkedin-direct" : row.companySource ?? null;
4705
- row.matchedCompanyName = company.matchedCompanyName ?? row.matchedCompanyName ?? null;
7882
+ if (!row.linkedinCompanyUrl && company.linkedin_company_url) {
7883
+ row.linkedinCompanyUrl = company.linkedin_company_url;
7884
+ }
7885
+ if (!row.salesNavCompanyUrl && company.sales_nav_company_url) {
7886
+ row.salesNavCompanyUrl = company.sales_nav_company_url;
7887
+ }
7888
+ row.matchedCompanyName = company.matched_company_name ?? row.matchedCompanyName ?? null;
4706
7889
  row.matchedCompanyEmployeeCount =
4707
- company.matchedCompanyEmployeeCount ?? row.matchedCompanyEmployeeCount ?? null;
7890
+ company.matched_company_employee_count ?? row.matchedCompanyEmployeeCount ?? null;
7891
+ if ((company.linkedin_company_url || company.sales_nav_company_url) && row.source == null) {
7892
+ row.source = "linkedin-direct";
7893
+ }
7894
+ row.found = Boolean(row.linkedinCompanyUrl || row.salesNavCompanyUrl);
4708
7895
  }
4709
7896
  }
4710
7897
  catch (error) {
4711
- writeProgress(`Skipping separate company enrichment: ${error instanceof Error ? error.message : String(error)}`);
7898
+ const message = error instanceof Error ? error.message : String(error);
7899
+ if (!/Missing LinkedIn direct lookup session/i.test(message)) {
7900
+ throw error;
7901
+ }
7902
+ }
7903
+ for (const row of results) {
7904
+ if (row.linkedinCompanyUrl) {
7905
+ continue;
7906
+ }
7907
+ const linkedinCompanyUrl = (await searchSerperLinkedInCompanyUrl(row.companyName, timeoutMs)) ??
7908
+ (await searchPublicLinkedInCompanyUrl(row.companyName, timeoutMs));
7909
+ if (!linkedinCompanyUrl) {
7910
+ continue;
7911
+ }
7912
+ row.linkedinCompanyUrl = linkedinCompanyUrl;
7913
+ row.found = true;
7914
+ if (row.source == null) {
7915
+ row.source = "web-search";
7916
+ }
4712
7917
  }
4713
7918
  const payload = {
4714
7919
  status: "ok",
4715
- orgId: String(options.orgId ?? "").trim() || null,
4716
7920
  requested: rows.length,
4717
- found: enrichedRows.filter((row) => row.found).length,
4718
- companiesFound: enrichedRows.filter((row) => row.companyFound).length,
4719
- directAttempted,
4720
- rows: enrichedRows
7921
+ found: results.filter((row) => row.found).length,
7922
+ rows: results
4721
7923
  };
4722
7924
  if (options.out) {
4723
7925
  await writeJsonFile(options.out, payload);
@@ -4734,10 +7936,19 @@ program
4734
7936
  program.hook("preAction", async (_thisCommand, actionCommand) => {
4735
7937
  applyGlobalOutputOptions(actionCommand);
4736
7938
  const commandName = actionCommand.name();
7939
+ const parentCommandName = actionCommand.parent && typeof actionCommand.parent.name === "function"
7940
+ ? actionCommand.parent.name()
7941
+ : "";
4737
7942
  if (commandName.startsWith("auth:") ||
7943
+ commandName === "setup" ||
7944
+ commandName === "doctor" ||
7945
+ commandName === "upgrade" ||
4738
7946
  commandName === "wizard" ||
4739
7947
  commandName === "llm:ready" ||
4740
- commandName === "contacts:find-linkedin-urls") {
7948
+ commandName === "contacts:find-linkedin-urls" ||
7949
+ commandName === "companies:find-linkedin-urls" ||
7950
+ commandName.startsWith("packs:") ||
7951
+ ((commandName === "list" || commandName === "add") && parentCommandName === "packs")) {
4741
7952
  return;
4742
7953
  }
4743
7954
  const commandOptions = actionCommand.opts();
@@ -4889,12 +8100,14 @@ program
4889
8100
  });
4890
8101
  program
4891
8102
  .command("leads:generate")
4892
- .description("Generate leads for a target account or from fallback seeds.")
8103
+ .description("Generate leads from your Salesprompter workspace when authenticated, or from fallback seeds.")
4893
8104
  .requiredOption("--icp <path>", "Path to ICP JSON")
4894
8105
  .option("--count <number>", "Number of leads to generate", "10")
4895
8106
  .option("--domain <domain>", "Target a specific company domain like company.com")
4896
8107
  .option("--company-domain <domain>", "Deprecated alias for --domain")
4897
8108
  .option("--company-name <name>", "Optional company name override for a targeted domain")
8109
+ .option("--linkedin-company-page <url>", "LinkedIn company page to target when the domain is unknown")
8110
+ .option("--source <source>", "auto|workspace|fallback", "auto")
4898
8111
  .requiredOption("--out <path>", "Output file path")
4899
8112
  .action(async (options) => {
4900
8113
  const icp = await readJsonFile(options.icp, IcpSchema);
@@ -4902,9 +8115,15 @@ program
4902
8115
  const domain = options.domain ?? options.companyDomain;
4903
8116
  const target = {
4904
8117
  companyDomain: domain,
4905
- companyName: options.companyName
8118
+ companyName: options.companyName,
8119
+ linkedinCompanyPage: options.linkedinCompanyPage
4906
8120
  };
4907
- const result = await leadProvider.generateLeads(icp, count, target);
8121
+ const result = await generateLeadsForCommand({
8122
+ icp,
8123
+ count,
8124
+ target,
8125
+ source: options.source
8126
+ });
4908
8127
  await writeJsonFile(options.out, result.leads);
4909
8128
  printOutput({
4910
8129
  status: "ok",
@@ -4949,6 +8168,8 @@ program
4949
8168
  .option("--domain <domain>", "Target a specific company domain like company.com")
4950
8169
  .option("--company-domain <domain>", "Deprecated alias for --domain")
4951
8170
  .option("--company-name <name>", "Optional company name override for a targeted domain")
8171
+ .option("--linkedin-company-page <url>", "LinkedIn company page to target when the domain is unknown")
8172
+ .option("--source <source>", "auto|workspace|fallback", "auto")
4952
8173
  .option("--out-prefix <path>", "Output path prefix (writes <prefix>-leads.json, <prefix>-enriched.json, <prefix>-scored.json)", "./data/leads-pipeline")
4953
8174
  .action(async (options) => {
4954
8175
  const icp = await readJsonFile(options.icp, IcpSchema);
@@ -4956,13 +8177,19 @@ program
4956
8177
  const domain = options.domain ?? options.companyDomain;
4957
8178
  const target = {
4958
8179
  companyDomain: domain,
4959
- companyName: options.companyName
8180
+ companyName: options.companyName,
8181
+ linkedinCompanyPage: options.linkedinCompanyPage
4960
8182
  };
4961
8183
  const outPrefix = String(options.outPrefix);
4962
8184
  const leadsOut = `${outPrefix}-leads.json`;
4963
8185
  const enrichedOut = `${outPrefix}-enriched.json`;
4964
8186
  const scoredOut = `${outPrefix}-scored.json`;
4965
- const generated = await leadProvider.generateLeads(icp, count, target);
8187
+ const generated = await generateLeadsForCommand({
8188
+ icp,
8189
+ count,
8190
+ target,
8191
+ source: options.source
8192
+ });
4966
8193
  await writeJsonFile(leadsOut, generated.leads);
4967
8194
  const enriched = await enrichmentProvider.enrichLeads(generated.leads);
4968
8195
  await writeJsonFile(enrichedOut, enriched);
@@ -5019,16 +8246,21 @@ program
5019
8246
  .command("linkedin-companies:backfill")
5020
8247
  .alias("companies:enrich")
5021
8248
  .description("Backfill missing or unavailable company profiles for the current workspace.")
5022
- .requiredOption("--client-id <number>", "Legacy BigQuery clientId to backfill")
8249
+ .option("--client-id <number>", "Legacy BigQuery clientId to backfill (optional if set in cache or env)")
5023
8250
  .option("--limit <number>", "Maximum companies to scrape in one run", "25")
5024
8251
  .option("--concurrency <number>", "How many LinkedIn company pages to scrape in parallel", "4")
5025
8252
  .option("--dry-run", "Preview the scrape result and generated MERGE SQL without writing to BigQuery", false)
5026
8253
  .action(async (options) => {
5027
- const clientId = z.coerce.number().int().positive().parse(options.clientId);
8254
+ const authenticatedRun = !shouldBypassAuth() && !options.dryRun;
8255
+ const session = authenticatedRun ? await requireAuthSession() : undefined;
8256
+ const clientId = await resolveLinkedInCompanyBackfillClientId({
8257
+ clientIdOption: options.clientId,
8258
+ session
8259
+ });
5028
8260
  const limit = z.coerce.number().int().min(1).max(500).parse(options.limit);
5029
8261
  const concurrency = z.coerce.number().int().min(1).max(20).parse(options.concurrency);
5030
- if (!options.dryRun && !shouldBypassAuth()) {
5031
- const session = await requireAuthSession();
8262
+ await writeLinkedInCompanyBackfillClientIdToCache(clientId, session);
8263
+ if (authenticatedRun && session) {
5032
8264
  const drained = await drainLinkedInCompanyBackfill(session, {
5033
8265
  clientId,
5034
8266
  limit
@@ -5695,6 +8927,7 @@ program
5695
8927
  .option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
5696
8928
  .option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
5697
8929
  .option("--slice-preset <name>", "Slice preset label stored with the export runs", "human-resources-crawl")
8930
+ .option("--client-id <number>", "Client id used to generate and store the legacy Neon lead list projection")
5698
8931
  .option("--max-split-depth <number>", "Maximum number of adaptive split dimensions to use", "6")
5699
8932
  .option("--max-slices <number>", "Safety cap for total claimed slices in this invocation", "1000")
5700
8933
  .option("--max-retries <number>", "Retries for non-splitting export failures", "3")
@@ -5713,6 +8946,7 @@ program
5713
8946
  const jobId = z.string().uuid().optional().parse(options.jobId);
5714
8947
  const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
5715
8948
  const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
8949
+ const clientId = parseOptionalSalesNavigatorClientId(options.clientId);
5716
8950
  const maxSplitDepth = z.coerce.number().int().min(1).max(6).parse(options.maxSplitDepth);
5717
8951
  const maxSlices = z.coerce.number().int().min(1).max(10000).parse(options.maxSlices);
5718
8952
  const maxRetries = z.coerce.number().int().min(0).max(5).parse(options.maxRetries);
@@ -5732,6 +8966,7 @@ program
5732
8966
  jobId: jobId ?? null,
5733
8967
  maxResultsPerSearch,
5734
8968
  numberOfProfiles,
8969
+ clientId,
5735
8970
  slicePreset: options.slicePreset,
5736
8971
  maxSplitDepth,
5737
8972
  maxSlices,
@@ -5832,6 +9067,7 @@ program
5832
9067
  traceId: logger.traceId,
5833
9068
  command: {
5834
9069
  sourceQueryUrl: queryUrl,
9070
+ clientId,
5835
9071
  slicePreset: options.slicePreset,
5836
9072
  maxResultsPerSearch,
5837
9073
  numberOfProfiles,
@@ -5853,6 +9089,7 @@ program
5853
9089
  splitTrail: seed.splitTrail,
5854
9090
  rawPayload: {
5855
9091
  workflow: "salesnav:crawl",
9092
+ clientId,
5856
9093
  traceId: logger.traceId
5857
9094
  }
5858
9095
  }
@@ -5892,6 +9129,7 @@ program
5892
9129
  idlePollSeconds,
5893
9130
  idleMaxPolls,
5894
9131
  parallelExports,
9132
+ clientId,
5895
9133
  traceId: logger.traceId,
5896
9134
  logger
5897
9135
  });
@@ -5972,6 +9210,43 @@ program
5972
9210
  recentEvents
5973
9211
  });
5974
9212
  });
9213
+ program
9214
+ .command("phantombuster:containers:sync")
9215
+ .alias("pb:containers:sync")
9216
+ .description("Fetch Phantombuster containers for configured agents and store them in Neon.")
9217
+ .option("--agent-id <id>", "Phantombuster agent id to sync. Repeat to sync multiple agents.", collectStringOptionValue, [])
9218
+ .option("--limit <number>", "Maximum containers to fetch per Phantombuster page", "100")
9219
+ .option("--max-pages <number>", "Maximum Phantombuster pages to fetch per agent", "50")
9220
+ .option("--mode <mode>", "Phantombuster container mode: all or finalized", "all")
9221
+ .option("--before-ended-at <iso>", "Only fetch containers that ended before this ISO timestamp")
9222
+ .option("--metadata-only", "Store container metadata without fetching output and result objects", false)
9223
+ .option("--out <path>", "Optional local JSON output path")
9224
+ .action(async (options) => {
9225
+ const agentIds = z.array(z.string().min(1)).parse(options.agentId);
9226
+ const limit = z.coerce.number().int().min(1).max(500).parse(options.limit);
9227
+ const maxPages = z.coerce.number().int().min(1).max(500).parse(options.maxPages);
9228
+ const mode = z.enum(["all", "finalized"]).parse(options.mode);
9229
+ const beforeEndedAt = options.beforeEndedAt
9230
+ ? z.string().datetime().parse(options.beforeEndedAt)
9231
+ : undefined;
9232
+ const session = await requireAuthSession();
9233
+ const result = await syncPhantombusterContainersViaApp(session, {
9234
+ agentIds: agentIds.length > 0 ? agentIds : undefined,
9235
+ limit,
9236
+ maxPages,
9237
+ mode,
9238
+ beforeEndedAt,
9239
+ includeResults: !options.metadataOnly
9240
+ });
9241
+ const payload = {
9242
+ ...result,
9243
+ dryRun: false
9244
+ };
9245
+ if (options.out) {
9246
+ await writeJsonFile(options.out, payload);
9247
+ }
9248
+ printOutput(payload);
9249
+ });
5975
9250
  program
5976
9251
  .command("salesnav:export")
5977
9252
  .alias("search:export")
@@ -5980,12 +9255,18 @@ program
5980
9255
  .option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
5981
9256
  .option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
5982
9257
  .option("--slice-preset <name>", "Slice preset label stored with the export run", "human-resources-default")
9258
+ .option("--client-id <number>", "Client id used to generate and store the legacy Neon lead list projection")
9259
+ .option("--agent-busy-wait-seconds <number>", "Seconds to wait before retrying when the export agent is already busy", "30")
9260
+ .option("--agent-busy-max-waits <number>", "How many busy-agent waits to tolerate before failing the export", "20")
5983
9261
  .option("--out <path>", "Optional local JSON output path")
5984
9262
  .option("--dry-run", "Only generate sliced query URLs without exporting them", false)
5985
9263
  .action(async (options) => {
5986
9264
  const queryUrls = z.array(z.string().url()).min(1).parse(options.queryUrl);
5987
9265
  const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
5988
9266
  const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
9267
+ const agentBusyWaitSeconds = z.coerce.number().int().min(1).max(300).parse(options.agentBusyWaitSeconds);
9268
+ const agentBusyMaxWaits = z.coerce.number().int().min(0).max(100).parse(options.agentBusyMaxWaits);
9269
+ const clientId = parseOptionalSalesNavigatorClientId(options.clientId);
5989
9270
  const prepared = queryUrls.map((queryUrl) => buildSalesNavigatorPeopleSlice(queryUrl));
5990
9271
  const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
5991
9272
  if (effectiveDryRun) {
@@ -6007,10 +9288,10 @@ program
6007
9288
  printOutput(payload);
6008
9289
  return;
6009
9290
  }
6010
- const session = await requireAuthSession();
9291
+ let session = await requireAuthSession();
6011
9292
  const exported = [];
6012
9293
  for (const item of prepared) {
6013
- const result = await runSalesNavigatorExport(session, {
9294
+ const result = await runSalesNavigatorExportWithAgentWait(session, {
6014
9295
  sourceQueryUrl: item.sourceQueryUrl,
6015
9296
  slicedQueryUrl: item.slicedQueryUrl,
6016
9297
  appliedFilters: item.appliedFilters,
@@ -6019,12 +9300,17 @@ program
6019
9300
  slicePreset: options.slicePreset,
6020
9301
  rawPayload: {
6021
9302
  workflow: "salesnav:export",
9303
+ clientId,
6022
9304
  sourceQueryUrl: item.sourceQueryUrl,
6023
9305
  slicedQueryUrl: item.slicedQueryUrl,
6024
9306
  appliedFilters: item.appliedFilters
6025
9307
  }
9308
+ }, {
9309
+ waitSeconds: agentBusyWaitSeconds,
9310
+ maxWaits: agentBusyMaxWaits
6026
9311
  });
6027
9312
  exported.push(result);
9313
+ session = await requireAuthSession();
6028
9314
  }
6029
9315
  const payload = {
6030
9316
  status: "ok",
@@ -6826,7 +10112,17 @@ async function main() {
6826
10112
  }
6827
10113
  await program.parseAsync(process.argv);
6828
10114
  }
6829
- main().catch((error) => {
10115
+ async function closeGlobalHttpDispatcher() {
10116
+ try {
10117
+ const undici = await import("undici");
10118
+ await undici.getGlobalDispatcher().close();
10119
+ }
10120
+ catch {
10121
+ // Best-effort shutdown for keep-alive sockets; ignore when undici is unavailable.
10122
+ }
10123
+ }
10124
+ main()
10125
+ .catch((error) => {
6830
10126
  if (error instanceof Error &&
6831
10127
  (error.message === "prompt cancelled" || error.message === "readline was closed")) {
6832
10128
  process.exitCode = 130;
@@ -6841,4 +10137,8 @@ main().catch((error) => {
6841
10137
  process.stderr.write(`${cliError.message}\n`);
6842
10138
  }
6843
10139
  process.exitCode = exitCodeForError(cliError.code);
10140
+ })
10141
+ .finally(async () => {
10142
+ await closeGlobalHttpDispatcher();
10143
+ process.exit(process.exitCode ?? 0);
6844
10144
  });