salesprompter-cli 0.1.35 → 0.1.36

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";
@@ -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());
@@ -120,6 +122,22 @@ const LinkedInCompanyBackfillStatusResponseSchema = z.object({
120
122
  failureCode: z.string().nullable().optional(),
121
123
  failureMessage: z.string().nullable().optional()
122
124
  });
125
+ const PhantombusterContainersSyncResponseSchema = z.object({
126
+ status: z.literal("ok"),
127
+ agentIds: z.array(z.string().min(1)),
128
+ agents: z.array(z.object({
129
+ agentId: z.string().min(1),
130
+ fetched: z.number().int().nonnegative(),
131
+ upserted: z.number().int().nonnegative(),
132
+ resultsSynced: z.number().int().nonnegative()
133
+ })),
134
+ fetched: z.number().int().nonnegative(),
135
+ upserted: z.number().int().nonnegative(),
136
+ resultsSynced: z.number().int().nonnegative(),
137
+ outputsStored: z.number().int().nonnegative(),
138
+ resultObjectsStored: z.number().int().nonnegative(),
139
+ resultRowsStored: z.number().int().nonnegative()
140
+ });
123
141
  const CliEmailEnrichmentCompaniesResponseSchema = z.object({
124
142
  clientId: z.number().int().positive(),
125
143
  companies: z.array(z.object({
@@ -948,6 +966,13 @@ function splitLookupFullName(fullName) {
948
966
  function buildSyntheticLookupEmail(contactId) {
949
967
  return `linkedin-lookup+${contactId}@salesprompter.invalid`;
950
968
  }
969
+ function normalizeLinkedInLookupField(value) {
970
+ if (value == null) {
971
+ return undefined;
972
+ }
973
+ const normalized = normalizeLookupWhitespace(String(value));
974
+ return normalized || undefined;
975
+ }
951
976
  function looksLikeLookupCompanyRow(fullName, companyName) {
952
977
  const fullNameComparable = normalizeLooseMatchText(fullName);
953
978
  const companyComparable = normalizeLooseMatchText(companyName);
@@ -967,19 +992,32 @@ function parseLinkedInUrlLookupInput(content) {
967
992
  const parsed = z
968
993
  .array(z.object({
969
994
  clientId: z.union([z.string(), z.number()]).nullish(),
995
+ contactId: z.union([z.string(), z.number()]).nullish(),
996
+ companyId: z.union([z.string(), z.number()]).nullish(),
970
997
  fullName: z.string().nullish(),
971
998
  companyName: z.string().nullish(),
972
999
  email: z.string().nullish(),
973
- jobTitle: z.string().nullish()
1000
+ contact_email: z.string().nullish(),
1001
+ jobTitle: z.string().nullish(),
1002
+ jobtitle: z.string().nullish(),
1003
+ title: z.string().nullish(),
1004
+ linkedin_company_url: z.string().nullish(),
1005
+ linkedinCompanyUrl: z.string().nullish(),
1006
+ deep_dive_recommended_role: z.string().nullish(),
1007
+ deepDiveRecommendedRole: z.string().nullish()
974
1008
  }))
975
1009
  .parse(JSON.parse(trimmed));
976
1010
  return parsed
977
1011
  .map((row) => ({
978
1012
  clientId: row.clientId == null ? null : String(row.clientId).trim() || null,
1013
+ contactId: row.contactId == null ? undefined : String(row.contactId).trim() || undefined,
1014
+ companyId: row.companyId == null ? undefined : String(row.companyId).trim() || undefined,
979
1015
  fullName: row.fullName?.trim() ?? "",
980
1016
  companyName: row.companyName?.trim() ?? "",
981
- email: row.email?.trim() || undefined,
982
- jobTitle: row.jobTitle?.trim() || undefined
1017
+ email: row.email?.trim() || row.contact_email?.trim() || undefined,
1018
+ jobTitle: row.jobTitle?.trim() || row.jobtitle?.trim() || row.title?.trim() || undefined,
1019
+ linkedinCompanyUrl: row.linkedin_company_url?.trim() || row.linkedinCompanyUrl?.trim() || undefined,
1020
+ deepDiveRecommendedRole: row.deep_dive_recommended_role?.trim() || row.deepDiveRecommendedRole?.trim() || undefined
983
1021
  }))
984
1022
  .filter((row) => row.fullName.length > 0 || row.companyName.length > 0);
985
1023
  }
@@ -1007,17 +1045,35 @@ function parseLinkedInUrlLookupInput(content) {
1007
1045
  ? headerValues.findIndex((value) => ["companyname", "company_name"].includes(value))
1008
1046
  : 2;
1009
1047
  const emailIndex = hasHeader ? headerValues.findIndex((value) => value === "email") : -1;
1048
+ const contactEmailIndex = hasHeader ? headerValues.findIndex((value) => value === "contact_email") : -1;
1010
1049
  const jobTitleIndex = hasHeader
1011
1050
  ? headerValues.findIndex((value) => ["jobtitle", "job_title", "title"].includes(value))
1012
1051
  : -1;
1052
+ const contactIdIndex = hasHeader
1053
+ ? headerValues.findIndex((value) => ["contactid", "contact_id", "hubspot_contact_id"].includes(value))
1054
+ : -1;
1055
+ const companyIdIndex = hasHeader
1056
+ ? headerValues.findIndex((value) => ["companyid", "company_id", "hubspot_company_id"].includes(value))
1057
+ : -1;
1058
+ const linkedinCompanyUrlIndex = hasHeader
1059
+ ? headerValues.findIndex((value) => ["linkedin_company_url", "linkedincompanyurl"].includes(value))
1060
+ : -1;
1061
+ const deepDiveRecommendedRoleIndex = hasHeader
1062
+ ? headerValues.findIndex((value) => ["deep_dive_recommended_role", "deepdiverecommendedrole"].includes(value))
1063
+ : -1;
1013
1064
  return dataLines
1014
1065
  .map((line) => splitLooseDelimitedLine(line, delimiter).map((value) => value.trim()))
1015
1066
  .map((columns) => ({
1016
1067
  clientId: clientIdIndex >= 0 ? columns[clientIdIndex] || null : null,
1068
+ contactId: contactIdIndex >= 0 ? columns[contactIdIndex] || undefined : undefined,
1069
+ companyId: companyIdIndex >= 0 ? columns[companyIdIndex] || undefined : undefined,
1017
1070
  fullName: fullNameIndex >= 0 ? columns[fullNameIndex] || "" : "",
1018
1071
  companyName: companyNameIndex >= 0 ? columns[companyNameIndex] || "" : "",
1019
- email: emailIndex >= 0 ? columns[emailIndex] || undefined : undefined,
1020
- jobTitle: jobTitleIndex >= 0 ? columns[jobTitleIndex] || undefined : undefined
1072
+ email: (emailIndex >= 0 ? columns[emailIndex] || undefined : undefined) ??
1073
+ (contactEmailIndex >= 0 ? columns[contactEmailIndex] || undefined : undefined),
1074
+ jobTitle: jobTitleIndex >= 0 ? columns[jobTitleIndex] || undefined : undefined,
1075
+ linkedinCompanyUrl: linkedinCompanyUrlIndex >= 0 ? columns[linkedinCompanyUrlIndex] || undefined : undefined,
1076
+ deepDiveRecommendedRole: deepDiveRecommendedRoleIndex >= 0 ? columns[deepDiveRecommendedRoleIndex] || undefined : undefined
1021
1077
  }))
1022
1078
  .filter((row) => row.fullName.length > 0 || row.companyName.length > 0);
1023
1079
  }
@@ -1072,7 +1128,7 @@ function parseLinkedInCompanyLookupInput(content) {
1072
1128
  }
1073
1129
  function toLinkedInUrlLookupContacts(rows, cleanedCompanyMap = new Map()) {
1074
1130
  return rows.flatMap((row, index) => {
1075
- const contactId = String(index + 1);
1131
+ const contactId = normalizeLinkedInLookupField(row.contactId) ?? String(index + 1);
1076
1132
  const syntheticEmail = row.email?.trim() || buildSyntheticLookupEmail(contactId);
1077
1133
  const rawCompanyName = normalizeLookupWhitespace(row.companyName);
1078
1134
  const cleanedCompanyName = normalizeLookupCompanyForSearch(cleanedCompanyMap.get(normalizeLookupCompanyForCleaning(rawCompanyName)) ?? rawCompanyName);
@@ -1086,7 +1142,10 @@ function toLinkedInUrlLookupContacts(rows, cleanedCompanyMap = new Map()) {
1086
1142
  companyName: cleanedCompanyName,
1087
1143
  companyNameOriginal: rawCompanyName || undefined,
1088
1144
  email: syntheticEmail,
1089
- jobTitle: row.jobTitle
1145
+ jobTitle: row.jobTitle,
1146
+ companyId: normalizeLinkedInLookupField(row.companyId),
1147
+ linkedinCompanyUrl: row.linkedinCompanyUrl?.trim() || undefined,
1148
+ deepDiveRecommendedRole: row.deepDiveRecommendedRole?.trim() || undefined
1090
1149
  }
1091
1150
  ];
1092
1151
  }
@@ -1101,7 +1160,10 @@ function toLinkedInUrlLookupContacts(rows, cleanedCompanyMap = new Map()) {
1101
1160
  companyName: cleanedCompanyName,
1102
1161
  companyNameOriginal: rawCompanyName || undefined,
1103
1162
  email: syntheticEmail,
1104
- jobTitle: row.jobTitle
1163
+ jobTitle: row.jobTitle,
1164
+ companyId: normalizeLinkedInLookupField(row.companyId),
1165
+ linkedinCompanyUrl: row.linkedinCompanyUrl?.trim() || undefined,
1166
+ deepDiveRecommendedRole: row.deepDiveRecommendedRole?.trim() || undefined
1105
1167
  }
1106
1168
  ];
1107
1169
  const rawDiffers = rawSplit.firstName !== cleanedSplit.firstName ||
@@ -1115,6 +1177,9 @@ function toLinkedInUrlLookupContacts(rows, cleanedCompanyMap = new Map()) {
1115
1177
  companyNameOriginal: rawCompanyName || undefined,
1116
1178
  email: syntheticEmail,
1117
1179
  jobTitle: row.jobTitle,
1180
+ companyId: normalizeLinkedInLookupField(row.companyId),
1181
+ linkedinCompanyUrl: row.linkedinCompanyUrl?.trim() || undefined,
1182
+ deepDiveRecommendedRole: row.deepDiveRecommendedRole?.trim() || undefined,
1118
1183
  isVariation: true
1119
1184
  });
1120
1185
  }
@@ -1137,10 +1202,132 @@ function readPipedreamLinkedInEnrichmentConfig() {
1137
1202
  projectEnvironment: resolveConfiguredEnvValue(process.env, "PIPEDREAM_PROJECT_ENVIRONMENT") || ""
1138
1203
  };
1139
1204
  }
1205
+ function isSyntheticLinkedInLookupEmail(value) {
1206
+ const normalized = normalizeLookupWhitespace(value).toLowerCase();
1207
+ return normalized.endsWith("@salesprompter.invalid");
1208
+ }
1140
1209
  function deriveCsrfTokenFromCookie(cookie) {
1141
1210
  const match = cookie.match(/JSESSIONID="?([^";]+)"?/i);
1142
1211
  return match?.[1]?.trim() || "";
1143
1212
  }
1213
+ function normalizeLinkedInDirectLookupCookieHeader(cookie) {
1214
+ const trimmed = normalizeLookupWhitespace(cookie);
1215
+ if (!trimmed) {
1216
+ return "";
1217
+ }
1218
+ if (trimmed.includes("=") || trimmed.includes(";")) {
1219
+ return trimmed;
1220
+ }
1221
+ return `li_at=${trimmed}`;
1222
+ }
1223
+ function parseLocalLinkedInExtensionTokenLog(content) {
1224
+ const matches = [
1225
+ ...content.matchAll(/\{"csrfToken":"([^"]+)","extractedFrom":"sales-api\/salesApiLeadSearch"[\s\S]*?"linkedInIdentity":"([^"]+)"[\s\S]*?"sessionCookie":"([\s\S]*?)","syncStatus":"(success|captured)"[\s\S]*?"userAgent":"([^"]+)"\}/g)
1226
+ ];
1227
+ const last = matches.at(-1);
1228
+ if (!last) {
1229
+ return null;
1230
+ }
1231
+ const csrfToken = normalizeLookupWhitespace(last[1]);
1232
+ const linkedInIdentity = normalizeLookupWhitespace(last[2]);
1233
+ const sessionCookie = normalizeLookupWhitespace(last[3]?.replace(/\\"/g, "\"").replace(/\\\\/g, "\\"));
1234
+ const userAgent = normalizeLookupWhitespace(last[5]);
1235
+ if (!csrfToken || !linkedInIdentity || !sessionCookie || !userAgent) {
1236
+ return null;
1237
+ }
1238
+ return {
1239
+ csrfToken,
1240
+ linkedInIdentity,
1241
+ sessionCookie,
1242
+ userAgent
1243
+ };
1244
+ }
1245
+ async function readLocalLinkedInExtensionTokenLog(filePath) {
1246
+ try {
1247
+ const content = await readFile(filePath, "latin1");
1248
+ return parseLocalLinkedInExtensionTokenLog(content);
1249
+ }
1250
+ catch {
1251
+ return null;
1252
+ }
1253
+ }
1254
+ async function listChromeExtensionTokenLogCandidates() {
1255
+ const overrideFile = normalizeLookupWhitespace(process.env.SALESPROMPTER_LINKEDIN_EXTENSION_TOKENS_LOG_PATH);
1256
+ if (overrideFile) {
1257
+ return [overrideFile];
1258
+ }
1259
+ const overrideDir = normalizeLookupWhitespace(process.env.SALESPROMPTER_LINKEDIN_EXTENSION_TOKENS_DIR);
1260
+ if (overrideDir) {
1261
+ try {
1262
+ const files = await readdir(overrideDir);
1263
+ return files
1264
+ .filter((file) => file.endsWith(".log") || file.endsWith(".ldb"))
1265
+ .map((file) => path.join(overrideDir, file))
1266
+ .sort()
1267
+ .reverse();
1268
+ }
1269
+ catch {
1270
+ return [];
1271
+ }
1272
+ }
1273
+ const chromeRootCandidates = [
1274
+ path.join(os.homedir(), "Library", "Application Support", "Google", "Chrome"),
1275
+ path.join(os.homedir(), "Library", "Application Support", "Chromium")
1276
+ ];
1277
+ const paths = [];
1278
+ for (const chromeRoot of chromeRootCandidates) {
1279
+ let profileDirs = [];
1280
+ try {
1281
+ profileDirs = await readdir(chromeRoot);
1282
+ }
1283
+ catch {
1284
+ continue;
1285
+ }
1286
+ for (const profileDir of profileDirs) {
1287
+ const extensionSettingsRoot = path.join(chromeRoot, profileDir, "Local Extension Settings");
1288
+ let extensionIds = [];
1289
+ try {
1290
+ extensionIds = await readdir(extensionSettingsRoot);
1291
+ }
1292
+ catch {
1293
+ continue;
1294
+ }
1295
+ for (const extensionId of extensionIds) {
1296
+ const extensionDir = path.join(extensionSettingsRoot, extensionId);
1297
+ let files = [];
1298
+ try {
1299
+ files = await readdir(extensionDir);
1300
+ }
1301
+ catch {
1302
+ continue;
1303
+ }
1304
+ for (const file of files) {
1305
+ if (!file.endsWith(".log")) {
1306
+ continue;
1307
+ }
1308
+ paths.push(path.join(extensionDir, file));
1309
+ }
1310
+ }
1311
+ }
1312
+ }
1313
+ return paths.sort().reverse();
1314
+ }
1315
+ async function readLocalLinkedInExtensionDirectLookupConfig() {
1316
+ const candidates = await listChromeExtensionTokenLogCandidates();
1317
+ for (const candidate of candidates) {
1318
+ const snapshot = await readLocalLinkedInExtensionTokenLog(candidate);
1319
+ if (!snapshot) {
1320
+ continue;
1321
+ }
1322
+ return {
1323
+ csrfToken: snapshot.csrfToken,
1324
+ identity: snapshot.linkedInIdentity,
1325
+ cookie: normalizeLinkedInDirectLookupCookieHeader(snapshot.sessionCookie),
1326
+ userAgent: snapshot.userAgent
1327
+ };
1328
+ }
1329
+ return null;
1330
+ }
1144
1331
  function readLinkedInDirectLookupEnvConfig() {
1145
1332
  const cookie = process.env.SALESPROMPTER_LINKEDIN_SALES_NAV_COOKIE?.trim() ||
1146
1333
  process.env.LINKEDIN_SALES_NAV_COOKIE?.trim() ||
@@ -1157,7 +1344,7 @@ function readLinkedInDirectLookupEnvConfig() {
1157
1344
  return {
1158
1345
  csrfToken,
1159
1346
  identity,
1160
- cookie,
1347
+ cookie: normalizeLinkedInDirectLookupCookieHeader(cookie),
1161
1348
  userAgent: process.env.SALESPROMPTER_LINKEDIN_USER_AGENT?.trim() ||
1162
1349
  "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"
1163
1350
  };
@@ -1207,7 +1394,7 @@ async function readStoredLinkedInDirectLookupConfig() {
1207
1394
  return {
1208
1395
  csrfToken,
1209
1396
  identity,
1210
- cookie: claimed.sessionCookie,
1397
+ cookie: normalizeLinkedInDirectLookupCookieHeader(claimed.sessionCookie),
1211
1398
  userAgent
1212
1399
  };
1213
1400
  }
@@ -1221,6 +1408,11 @@ async function readLinkedInDirectLookupConfig() {
1221
1408
  cachedLinkedInDirectLookupConfig = envConfig;
1222
1409
  return envConfig;
1223
1410
  }
1411
+ const localExtensionConfig = await readLocalLinkedInExtensionDirectLookupConfig();
1412
+ if (localExtensionConfig) {
1413
+ cachedLinkedInDirectLookupConfig = localExtensionConfig;
1414
+ return localExtensionConfig;
1415
+ }
1224
1416
  const storedConfig = await readStoredLinkedInDirectLookupConfig();
1225
1417
  if (storedConfig) {
1226
1418
  cachedLinkedInDirectLookupConfig = storedConfig;
@@ -1237,46 +1429,200 @@ function buildLinkedInSalesApiUrl(params) {
1237
1429
  const encodedFirstName = encodeURIComponent(params.firstName);
1238
1430
  const encodedLastName = encodeURIComponent(params.lastName);
1239
1431
  const encodedCompanyName = encodeURIComponent(params.companyName);
1432
+ const encodedKeywords = encodeURIComponent(params.keywordsText?.trim() || params.companyName);
1240
1433
  const filters = params.searchMode === "current_company"
1241
1434
  ? `(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)))`
1242
1435
  : `(type:FIRST_NAME,values:List((text:${encodedFirstName},selectionType:INCLUDED))),(type:LAST_NAME,values:List((text:${encodedLastName},selectionType:INCLUDED)))`;
1243
- const keywordsSegment = params.searchMode === "keywords" ? `,keywords:${encodedCompanyName}` : "";
1436
+ const keywordsSegment = params.searchMode === "current_company" ? "" : `,keywords:${encodedKeywords}`;
1244
1437
  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`;
1245
1438
  }
1439
+ function extractLookupTitleKeywords(value) {
1440
+ const shortAllowlist = new Set(["hr", "it", "cfo"]);
1441
+ return normalizeLooseMatchText(value)
1442
+ .split(/\s+/)
1443
+ .filter((token) => token.length >= 4 || shortAllowlist.has(token))
1444
+ .filter((token) => ![
1445
+ "head",
1446
+ "senior",
1447
+ "consultant",
1448
+ "manager",
1449
+ "specialist",
1450
+ "lead",
1451
+ "global",
1452
+ "team",
1453
+ "group"
1454
+ ].includes(token))
1455
+ .slice(0, 4);
1456
+ }
1457
+ function buildDeepDiveRoleSearchKeywords(role) {
1458
+ const normalized = normalizeLooseMatchText(role);
1459
+ switch (normalized) {
1460
+ case "budgetholder":
1461
+ return ["finance", "procurement", "purchasing", "accounting", "controlling", "cfo"];
1462
+ case "decisionmaker":
1463
+ return ["director", "head", "vp", "chief", "leiter", "lead"];
1464
+ case "champion":
1465
+ return ["hr", "workplace", "operations", "it", "people", "office"];
1466
+ case "executivesponsor":
1467
+ return ["executive", "board", "chief", "managing", "director", "ceo"];
1468
+ case "influencer":
1469
+ return ["specialist", "manager", "consultant", "project", "workplace", "hr"];
1470
+ case "legalandcompliance":
1471
+ return ["legal", "compliance", "datenschutz", "counsel"];
1472
+ case "blocker":
1473
+ return ["procurement", "legal", "compliance", "security"];
1474
+ case "enduser":
1475
+ return ["workplace", "office", "operations", "assistant", "admin"];
1476
+ default:
1477
+ return [];
1478
+ }
1479
+ }
1246
1480
  function buildLinkedInAccountSearchApiUrl(companyName) {
1247
1481
  const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
1248
1482
  "https://www.linkedin.com";
1249
1483
  const encodedCompanyName = encodeURIComponent(companyName);
1250
1484
  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`;
1251
1485
  }
1252
- function buildLinkedInLookupSearchVariants(contact) {
1486
+ async function buildLinkedInLookupSearchVariants(contact, timeoutMs, resolvedCompanyAliases = []) {
1253
1487
  const variants = [];
1254
1488
  const seen = new Set();
1255
- const companyCandidates = [
1256
- normalizeLookupWhitespace(contact.companyName),
1257
- normalizeLookupWhitespace(contact.companyNameOriginal)
1258
- ].filter(Boolean);
1259
- for (const companyName of companyCandidates) {
1260
- for (const searchMode of ["current_company", "keywords"]) {
1261
- const key = [
1262
- contact.firstName.trim().toLowerCase(),
1263
- contact.lastName.trim().toLowerCase(),
1264
- companyName.toLowerCase(),
1265
- searchMode
1266
- ].join("|");
1267
- if (seen.has(key)) {
1268
- continue;
1269
- }
1270
- seen.add(key);
1271
- variants.push({
1272
- firstName: contact.firstName,
1273
- lastName: contact.lastName,
1274
- companyName,
1275
- searchMode
1276
- });
1489
+ const companyCandidateScores = new Map();
1490
+ const addCompanyCandidate = (value, score) => {
1491
+ const normalized = normalizeLookupWhitespace(value);
1492
+ if (!normalized) {
1493
+ return;
1494
+ }
1495
+ companyCandidateScores.set(normalized, Math.max(score, companyCandidateScores.get(normalized) ?? 0));
1496
+ };
1497
+ addCompanyCandidate(contact.companyName, 80);
1498
+ addCompanyCandidate(contact.companyNameOriginal, 70);
1499
+ const linkedInHandle = normalizeLinkedInCompanyHandle(contact.linkedinCompanyUrl ?? "");
1500
+ if (linkedInHandle && !/^\d+$/.test(linkedInHandle)) {
1501
+ addCompanyCandidate(linkedInHandle.replace(/[-_]+/g, " "), 95);
1502
+ }
1503
+ for (const alias of resolvedCompanyAliases) {
1504
+ addCompanyCandidate(alias, 110);
1505
+ }
1506
+ const emailDomain = (() => {
1507
+ const email = normalizeLookupWhitespace(contact.email);
1508
+ if (!email || isSyntheticLinkedInLookupEmail(email)) {
1509
+ return "";
1510
+ }
1511
+ const at = email.lastIndexOf("@");
1512
+ return at >= 0 ? email.slice(at + 1) : "";
1513
+ })();
1514
+ if (emailDomain) {
1515
+ const host = emailDomain.replace(/^www\./i, "").split(".")[0] ?? "";
1516
+ if (host) {
1517
+ addCompanyCandidate(host.replace(/[-_]+/g, " "), 100);
1277
1518
  }
1278
1519
  }
1279
- return variants;
1520
+ if (contact.jobTitle && contact.deepDiveRecommendedRole) {
1521
+ const primaryWord = normalizeLookupWhitespace(contact.companyNameOriginal ?? contact.companyName)
1522
+ .split(/\s+/)
1523
+ .filter((part) => part.length >= 4)
1524
+ .slice(-1)[0];
1525
+ if (primaryWord) {
1526
+ addCompanyCandidate(primaryWord, 45);
1527
+ }
1528
+ }
1529
+ const companyHints = await buildLinkedInProfileCompanyHints(contact, timeoutMs);
1530
+ for (const phrase of companyHints.phrases) {
1531
+ const tokenCount = normalizeLooseMatchText(phrase).split(/\s+/).filter(Boolean).length;
1532
+ if (tokenCount >= 1 && tokenCount <= 4) {
1533
+ addCompanyCandidate(phrase, tokenCount <= 2 ? 75 : 60);
1534
+ }
1535
+ }
1536
+ for (const keyword of companyHints.keywords.slice(0, 5)) {
1537
+ addCompanyCandidate(keyword, keyword.includes(".") ? 90 : 55);
1538
+ }
1539
+ const titleKeywords = Array.from(new Set([
1540
+ ...extractLookupTitleKeywords(contact.jobTitle),
1541
+ ...buildDeepDiveRoleSearchKeywords(contact.deepDiveRecommendedRole)
1542
+ ])).slice(0, 6);
1543
+ const rankedCompanyCandidates = Array.from(companyCandidateScores.entries())
1544
+ .sort((left, right) => right[1] - left[1] || left[0].length - right[0].length)
1545
+ .slice(0, 6);
1546
+ const emailHostCandidate = (() => {
1547
+ if (!emailDomain) {
1548
+ return "";
1549
+ }
1550
+ return normalizeLookupWhitespace(emailDomain.replace(/^www\./i, "").split(".")[0] ?? "").replace(/[-_]+/g, " ");
1551
+ })();
1552
+ const cleanCompanyCandidate = normalizeLookupWhitespace(contact.companyName) ||
1553
+ normalizeLookupWhitespace(contact.companyNameOriginal) ||
1554
+ "";
1555
+ const linkedInHandleCandidate = linkedInHandle && !/^\d+$/.test(linkedInHandle)
1556
+ ? normalizeLookupWhitespace(linkedInHandle.replace(/[-_]+/g, " "))
1557
+ : "";
1558
+ const pushVariant = (companyName, searchMode) => {
1559
+ const normalizedCompany = normalizeLookupWhitespace(companyName);
1560
+ if (!normalizedCompany) {
1561
+ return;
1562
+ }
1563
+ const keywordsText = searchMode === "keywords_title" && titleKeywords.length > 0
1564
+ ? `${normalizedCompany} ${titleKeywords.join(" ")}`
1565
+ : undefined;
1566
+ if (searchMode === "keywords_title" && !keywordsText) {
1567
+ return;
1568
+ }
1569
+ const key = [
1570
+ contact.firstName.trim().toLowerCase(),
1571
+ contact.lastName.trim().toLowerCase(),
1572
+ normalizedCompany.toLowerCase(),
1573
+ searchMode,
1574
+ keywordsText?.toLowerCase() ?? ""
1575
+ ].join("|");
1576
+ if (seen.has(key)) {
1577
+ return;
1578
+ }
1579
+ seen.add(key);
1580
+ variants.push({
1581
+ firstName: contact.firstName,
1582
+ lastName: contact.lastName,
1583
+ companyName: normalizedCompany,
1584
+ searchMode,
1585
+ keywordsText
1586
+ });
1587
+ };
1588
+ const rankedCompanyNames = rankedCompanyCandidates.map(([companyName]) => companyName);
1589
+ const currentCompanyStageCandidates = [
1590
+ emailHostCandidate,
1591
+ linkedInHandleCandidate,
1592
+ ...resolvedCompanyAliases,
1593
+ ...rankedCompanyNames.filter((companyName) => (companyCandidateScores.get(companyName) ?? 0) >= 90)
1594
+ ];
1595
+ const keywordStageCandidates = [
1596
+ cleanCompanyCandidate,
1597
+ ...rankedCompanyNames
1598
+ ];
1599
+ const keywordTitleStageCandidates = [
1600
+ cleanCompanyCandidate,
1601
+ ...rankedCompanyNames
1602
+ ];
1603
+ const fallbackCurrentCompanyCandidates = [
1604
+ cleanCompanyCandidate,
1605
+ normalizeLookupWhitespace(contact.companyNameOriginal),
1606
+ ...rankedCompanyNames
1607
+ ];
1608
+ for (const companyName of currentCompanyStageCandidates) {
1609
+ pushVariant(companyName, "current_company");
1610
+ }
1611
+ for (const companyName of keywordStageCandidates) {
1612
+ pushVariant(companyName, "keywords");
1613
+ }
1614
+ for (const companyName of keywordTitleStageCandidates) {
1615
+ pushVariant(companyName, "keywords_title");
1616
+ }
1617
+ for (const companyName of fallbackCurrentCompanyCandidates) {
1618
+ pushVariant(companyName, "current_company");
1619
+ }
1620
+ for (const [companyName] of rankedCompanyCandidates) {
1621
+ pushVariant(companyName, "current_company");
1622
+ pushVariant(companyName, "keywords");
1623
+ pushVariant(companyName, "keywords_title");
1624
+ }
1625
+ return variants.slice(0, 12);
1280
1626
  }
1281
1627
  function normalizeSalesNavLeadUrl(value) {
1282
1628
  const trimmed = String(value ?? "").trim();
@@ -1298,14 +1644,21 @@ function normalizePublicLinkedInProfileUrl(value) {
1298
1644
  if (!trimmed) {
1299
1645
  return null;
1300
1646
  }
1301
- const publicMatch = trimmed.match(/https:\/\/www\.linkedin\.com\/in\/[^/?#]+\/?/i);
1302
- if (!publicMatch) {
1647
+ let parsed;
1648
+ try {
1649
+ parsed = new URL(trimmed);
1650
+ }
1651
+ catch {
1652
+ return null;
1653
+ }
1654
+ if (!/(^|\.)linkedin\.com$/i.test(parsed.hostname)) {
1303
1655
  return null;
1304
1656
  }
1305
- const candidate = publicMatch[0] ?? null;
1306
- if (!candidate) {
1657
+ const pathMatch = parsed.pathname.match(/^\/in\/([^/?#]+)\/?/i);
1658
+ if (!pathMatch?.[1]) {
1307
1659
  return null;
1308
1660
  }
1661
+ const candidate = `https://www.linkedin.com/in/${pathMatch[1]}`;
1309
1662
  return normalizeSalesNavLeadUrl(candidate) ? null : candidate;
1310
1663
  }
1311
1664
  function extractLinkedInProfileUrlFromSalesApiElement(element) {
@@ -1448,6 +1801,112 @@ function extractLinkedInCompanyNameFromSalesApiElement(element) {
1448
1801
  }
1449
1802
  return null;
1450
1803
  }
1804
+ function extractLinkedInFullNameFromSalesApiElement(element) {
1805
+ if (!element) {
1806
+ return null;
1807
+ }
1808
+ const directCandidates = [
1809
+ typeof element.fullName === "string" ? element.fullName : null,
1810
+ typeof element.name === "string" ? element.name : null
1811
+ ].filter(Boolean);
1812
+ for (const candidate of directCandidates) {
1813
+ const normalized = normalizeLookupWhitespace(candidate);
1814
+ if (normalized) {
1815
+ return normalized;
1816
+ }
1817
+ }
1818
+ const firstName = typeof element.firstName === "string" ? normalizeLookupWhitespace(element.firstName) : "";
1819
+ const lastName = typeof element.lastName === "string" ? normalizeLookupWhitespace(element.lastName) : "";
1820
+ const combined = normalizeLookupWhitespace(`${firstName} ${lastName}`);
1821
+ return combined || null;
1822
+ }
1823
+ function extractLinkedInTitleFromSalesApiElement(element) {
1824
+ if (!element) {
1825
+ return null;
1826
+ }
1827
+ const directCandidates = [
1828
+ typeof element.title === "string" ? element.title : null,
1829
+ typeof element.occupation === "string" ? element.occupation : null
1830
+ ].filter(Boolean);
1831
+ for (const candidate of directCandidates) {
1832
+ const normalized = normalizeLookupWhitespace(candidate);
1833
+ if (normalized) {
1834
+ return normalized;
1835
+ }
1836
+ }
1837
+ const currentPosition = Array.isArray(element.currentPositions) && element.currentPositions.length > 0
1838
+ ? element.currentPositions[0]
1839
+ : null;
1840
+ const currentTitle = currentPosition && typeof currentPosition.title === "string"
1841
+ ? normalizeLookupWhitespace(currentPosition.title)
1842
+ : "";
1843
+ return currentTitle || null;
1844
+ }
1845
+ function scoreLinkedInSalesApiElementMatch(contact, element) {
1846
+ const fullName = extractLinkedInFullNameFromSalesApiElement(element);
1847
+ const companyName = extractLinkedInCompanyNameFromSalesApiElement(Array.isArray(element?.currentPositions) && element.currentPositions.length > 0
1848
+ ? element.currentPositions[0]
1849
+ : element) ?? extractLinkedInCompanyNameFromSalesApiElement(element);
1850
+ const title = extractLinkedInTitleFromSalesApiElement(element);
1851
+ const expectedFullName = normalizeLooseMatchText(`${contact.firstName} ${contact.lastName}`);
1852
+ const candidateFullName = normalizeLooseMatchText(fullName);
1853
+ const expectedCompanies = Array.from(new Set([
1854
+ normalizeLooseMatchText(contact.companyNameOriginal ?? contact.companyName),
1855
+ normalizeLooseMatchText(aggressivelyCleanLookupCompanyName(contact.companyNameOriginal ?? contact.companyName)),
1856
+ normalizeLooseMatchText(normalizeLinkedInCompanyHandle(contact.linkedinCompanyUrl ?? "")?.replace(/[-_]+/g, " ") ?? ""),
1857
+ normalizeLooseMatchText((() => {
1858
+ const email = normalizeLookupWhitespace(contact.email);
1859
+ if (!email || isSyntheticLinkedInLookupEmail(email)) {
1860
+ return "";
1861
+ }
1862
+ return email.split("@")[1]?.replace(/^www\./i, "").split(".")[0] ?? "";
1863
+ })())
1864
+ ].filter(Boolean)));
1865
+ const candidateCompany = normalizeLooseMatchText(companyName);
1866
+ const candidateTitle = normalizeLooseMatchText(title);
1867
+ let score = 0;
1868
+ let exactNameMatch = false;
1869
+ let companyMatchCount = 0;
1870
+ if (expectedFullName && candidateFullName === expectedFullName) {
1871
+ score += 120;
1872
+ exactNameMatch = true;
1873
+ }
1874
+ else if (expectedFullName &&
1875
+ candidateFullName.includes(normalizeLooseMatchText(contact.firstName)) &&
1876
+ candidateFullName.includes(normalizeLooseMatchText(contact.lastName))) {
1877
+ score += 90;
1878
+ }
1879
+ for (const companyHint of expectedCompanies) {
1880
+ if (!companyHint) {
1881
+ continue;
1882
+ }
1883
+ if (candidateCompany === companyHint) {
1884
+ score += 40;
1885
+ companyMatchCount += 1;
1886
+ }
1887
+ else if (candidateCompany.includes(companyHint) || companyHint.includes(candidateCompany)) {
1888
+ score += 25;
1889
+ companyMatchCount += 1;
1890
+ }
1891
+ }
1892
+ const titleHints = [
1893
+ ...extractLookupTitleKeywords(contact.jobTitle),
1894
+ ...buildDeepDiveRoleSearchKeywords(contact.deepDiveRecommendedRole)
1895
+ ].slice(0, 6);
1896
+ for (const hint of titleHints) {
1897
+ if (hint && candidateTitle.includes(normalizeLooseMatchText(hint))) {
1898
+ score += 6;
1899
+ }
1900
+ }
1901
+ return {
1902
+ score,
1903
+ fullName,
1904
+ companyName,
1905
+ title,
1906
+ exactNameMatch,
1907
+ companyMatchCount
1908
+ };
1909
+ }
1451
1910
  function extractLinkedInCompanyEmployeeCountFromSalesApiElement(element) {
1452
1911
  if (!element) {
1453
1912
  return null;
@@ -1496,6 +1955,111 @@ function buildLinkedInCompanyLookupVariants(params) {
1496
1955
  }
1497
1956
  return variants;
1498
1957
  }
1958
+ function buildDirectCompanyContextKey(contact) {
1959
+ return normalizeLooseMatchText(contact.companyNameOriginal ?? contact.companyName);
1960
+ }
1961
+ async function resolveDirectLinkedInCompanyContexts(params) {
1962
+ const perCompanyBudgetMs = Math.min(params.timeoutMs, 10_000);
1963
+ const primaryByCompany = new Map();
1964
+ for (const contact of params.contacts) {
1965
+ const key = buildDirectCompanyContextKey(contact);
1966
+ if (!key || primaryByCompany.has(key)) {
1967
+ continue;
1968
+ }
1969
+ primaryByCompany.set(key, contact);
1970
+ }
1971
+ const contexts = new Map();
1972
+ for (const [companyKey, contact] of primaryByCompany.entries()) {
1973
+ const aliases = new Set();
1974
+ const addAlias = (value) => {
1975
+ const normalized = normalizeLookupWhitespace(value);
1976
+ if (!normalized) {
1977
+ return;
1978
+ }
1979
+ aliases.add(normalized);
1980
+ };
1981
+ addAlias(contact.companyNameOriginal);
1982
+ addAlias(contact.companyName);
1983
+ const existingHandle = normalizeLinkedInCompanyHandle(contact.linkedinCompanyUrl ?? "");
1984
+ if (existingHandle && !/^\d+$/.test(existingHandle)) {
1985
+ addAlias(existingHandle.replace(/[-_]+/g, " "));
1986
+ }
1987
+ let matchedCompanyUrl = contact.linkedinCompanyUrl ?? null;
1988
+ let matchedSalesNavCompanyUrl = null;
1989
+ let matchedCompanyName = null;
1990
+ let matchedCompanyEmployeeCount = null;
1991
+ const companyDeadline = Date.now() + perCompanyBudgetMs;
1992
+ const variants = buildLinkedInCompanyLookupVariants({
1993
+ contactId: contact.contact_id,
1994
+ companyName: contact.companyName,
1995
+ companyNameOriginal: contact.companyNameOriginal
1996
+ }).slice(0, 4);
1997
+ for (const variant of variants) {
1998
+ if (Date.now() >= companyDeadline) {
1999
+ break;
2000
+ }
2001
+ const controller = new AbortController();
2002
+ const timeout = setTimeout(controller.abort.bind(controller), Math.min(6_000, Math.max(1_000, companyDeadline - Date.now())));
2003
+ try {
2004
+ const response = await fetch(buildLinkedInAccountSearchApiUrl(variant.companyName), {
2005
+ method: "GET",
2006
+ signal: controller.signal,
2007
+ headers: {
2008
+ accept: "*/*",
2009
+ "accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
2010
+ "csrf-token": params.config.csrfToken,
2011
+ referer: "https://www.linkedin.com/sales/search/company",
2012
+ "sec-fetch-dest": "empty",
2013
+ "sec-fetch-mode": "cors",
2014
+ "sec-fetch-site": "same-origin",
2015
+ "user-agent": params.config.userAgent,
2016
+ "x-li-identity": params.config.identity,
2017
+ "x-li-lang": "en_US",
2018
+ "x-li-page-instance": "urn:li:page:d_sales2_search_accounts;13Jvve6kRGCao+iP0wwAag==",
2019
+ "x-restli-protocol-version": "2.0.0",
2020
+ cookie: params.config.cookie
2021
+ }
2022
+ });
2023
+ if (!response.ok) {
2024
+ if (response.status === 429) {
2025
+ break;
2026
+ }
2027
+ continue;
2028
+ }
2029
+ const data = (await response.json());
2030
+ const first = data.elements?.[0];
2031
+ const companyUrl = extractLinkedInCompanyUrlFromSalesApiElement(first);
2032
+ const salesNavCompanyUrl = extractLinkedInSalesNavCompanyUrlFromSalesApiElement(first);
2033
+ const companyName = extractLinkedInCompanyNameFromSalesApiElement(first);
2034
+ if (companyUrl || salesNavCompanyUrl || companyName) {
2035
+ matchedCompanyUrl = companyUrl ?? matchedCompanyUrl;
2036
+ matchedSalesNavCompanyUrl = salesNavCompanyUrl ?? matchedSalesNavCompanyUrl;
2037
+ matchedCompanyName = companyName ?? matchedCompanyName;
2038
+ matchedCompanyEmployeeCount = extractLinkedInCompanyEmployeeCountFromSalesApiElement(first);
2039
+ addAlias(companyName);
2040
+ addAlias(companyUrl ? normalizeLinkedInCompanyHandle(companyUrl)?.replace(/[-_]+/g, " ") : null);
2041
+ addAlias(salesNavCompanyUrl ? normalizeLookupWhitespace(salesNavCompanyUrl.split("/sales/company/")[1]?.split(/[/?#]/)[0] ?? "") : null);
2042
+ break;
2043
+ }
2044
+ }
2045
+ catch {
2046
+ // Try next company variant.
2047
+ }
2048
+ finally {
2049
+ clearTimeout(timeout);
2050
+ }
2051
+ }
2052
+ contexts.set(companyKey, {
2053
+ normalizedCompanyKey: companyKey,
2054
+ aliases: Array.from(aliases),
2055
+ linkedinCompanyUrl: matchedCompanyUrl,
2056
+ salesNavCompanyUrl: matchedSalesNavCompanyUrl,
2057
+ matchedCompanyName,
2058
+ matchedCompanyEmployeeCount
2059
+ });
2060
+ }
2061
+ return contexts;
2062
+ }
1499
2063
  function buildPublicLinkedInCompanySearchUrl(companyName) {
1500
2064
  const baseUrl = process.env.SALESPROMPTER_LINKEDIN_COMPANY_SEARCH_BASE_URL?.trim() ||
1501
2065
  "https://duckduckgo.com/html/";
@@ -1559,7 +2123,8 @@ function extractSerperLinkedInCompanyCandidates(payload) {
1559
2123
  const organic = "organic" in payload && Array.isArray(payload.organic)
1560
2124
  ? (payload.organic ?? [])
1561
2125
  : [];
1562
- const candidates = new Set();
2126
+ const seen = new Set();
2127
+ const candidates = [];
1563
2128
  for (const result of organic) {
1564
2129
  if (!result || typeof result !== "object") {
1565
2130
  continue;
@@ -1569,60 +2134,685 @@ function extractSerperLinkedInCompanyCandidates(payload) {
1569
2134
  : "";
1570
2135
  const handle = normalizeLinkedInCompanyHandle(link);
1571
2136
  if (handle) {
1572
- candidates.add(normalizeLinkedInCompanyPage(handle));
2137
+ const url = normalizeLinkedInCompanyPage(handle);
2138
+ if (!seen.has(url)) {
2139
+ seen.add(url);
2140
+ candidates.push({
2141
+ url,
2142
+ title: "title" in result && typeof result.title === "string"
2143
+ ? normalizeLookupWhitespace(result.title)
2144
+ : "",
2145
+ snippet: "snippet" in result && typeof result.snippet === "string"
2146
+ ? normalizeLookupWhitespace(result.snippet)
2147
+ : ""
2148
+ });
2149
+ }
1573
2150
  }
1574
2151
  }
1575
- return Array.from(candidates);
1576
- }
1577
- function decodeHtmlEntities(value) {
1578
- return value
1579
- .replace(/&amp;/gi, "&")
1580
- .replace(/&quot;/gi, '"')
1581
- .replace(/&#39;/gi, "'")
1582
- .replace(/&lt;/gi, "<")
1583
- .replace(/&gt;/gi, ">");
2152
+ return candidates;
1584
2153
  }
1585
- async function fetchLinkedInCompanyPageSignals(url, timeoutMs) {
1586
- const controller = new AbortController();
1587
- const timeout = setTimeout(() => controller.abort(), Math.min(timeoutMs, 12_000));
1588
- try {
1589
- const response = await fetch(url, {
1590
- method: "GET",
1591
- signal: controller.signal,
1592
- headers: {
1593
- "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"
1594
- }
1595
- });
1596
- const html = await response.text();
1597
- const finalUrl = response.url || url;
1598
- const title = decodeHtmlEntities(html.match(/<title[^>]*>([^<]+)/i)?.[1] || "") ||
1599
- decodeHtmlEntities(html.match(/<meta[^>]+property="og:title"[^>]+content="([^"]+)/i)?.[1] || "");
1600
- const description = decodeHtmlEntities(html.match(/<meta[^>]+name="description"[^>]+content="([^"]+)/i)?.[1] || "");
1601
- const bodyText = decodeHtmlEntities(html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " "));
1602
- const unavailableText = normalizeLooseMatchText(`${title} ${description} ${bodyText}`);
1603
- const unavailable = response.status >= 400 ||
1604
- unavailableText.includes("page not found") ||
1605
- unavailableText.includes("this page does not exist") ||
1606
- unavailableText.includes("page isnt available");
1607
- const handle = normalizeLinkedInCompanyHandle(finalUrl) ?? normalizeLinkedInCompanyHandle(url);
1608
- if (!handle) {
1609
- return null;
2154
+ const linkedInCompanyHintCache = new Map();
2155
+ const linkedInProfilePageSignalCache = new Map();
2156
+ const linkedInCompanyPageSignalCache = new Map();
2157
+ const serperSearchCache = new Map();
2158
+ let serperCreditsExhausted = false;
2159
+ function extractKeywordPhrases(value) {
2160
+ const normalized = normalizeLookupWhitespace(value);
2161
+ if (!normalized) {
2162
+ return [];
2163
+ }
2164
+ const phrases = new Set();
2165
+ const push = (candidate) => {
2166
+ const cleaned = normalizeLookupWhitespace(candidate);
2167
+ if (!cleaned || cleaned.length < 3) {
2168
+ return;
1610
2169
  }
1611
- return {
1612
- normalizedUrl: normalizeLinkedInCompanyPage(handle),
2170
+ phrases.add(cleaned);
2171
+ };
2172
+ push(normalized);
2173
+ push(normalizeLookupCompanyForSearch(normalized));
2174
+ push(aggressivelyCleanLookupCompanyName(normalized));
2175
+ const titleStripped = normalized
2176
+ .replace(/\|\s*linkedin$/i, "")
2177
+ .replace(/\|\s*overview$/i, "")
2178
+ .replace(/\b(linkedin|home|about|posts|see all details)\b/gi, " ")
2179
+ .replace(/\s+/g, " ")
2180
+ .trim();
2181
+ push(titleStripped);
2182
+ const parts = titleStripped
2183
+ .split(/[|,·•:()/-]+/)
2184
+ .map((part) => normalizeLookupWhitespace(part))
2185
+ .filter(Boolean);
2186
+ for (const part of parts) {
2187
+ push(part);
2188
+ }
2189
+ const looseTokens = normalizeLooseMatchText(titleStripped)
2190
+ .split(/\s+/)
2191
+ .filter((token) => token.length >= 4)
2192
+ .filter((token) => ![
2193
+ "group",
2194
+ "holding",
2195
+ "services",
2196
+ "service",
2197
+ "consulting",
2198
+ "gmbh",
2199
+ "publishing",
2200
+ "company",
2201
+ "linkedin",
2202
+ "deutschland"
2203
+ ].includes(token));
2204
+ if (looseTokens.length > 0) {
2205
+ push(looseTokens[0]);
2206
+ push(looseTokens.slice(0, 2).join(" "));
2207
+ push(looseTokens.slice(-2).join(" "));
2208
+ }
2209
+ return Array.from(phrases);
2210
+ }
2211
+ async function buildLinkedInProfileCompanyHints(contact, timeoutMs) {
2212
+ const phrases = new Set();
2213
+ const keywords = new Set();
2214
+ const addPhrase = (value) => {
2215
+ for (const phrase of extractKeywordPhrases(value)) {
2216
+ phrases.add(phrase);
2217
+ const looseTokens = normalizeLooseMatchText(phrase)
2218
+ .split(/\s+/)
2219
+ .filter((token) => token.length >= 4)
2220
+ .filter((token) => ![
2221
+ "group",
2222
+ "holding",
2223
+ "services",
2224
+ "service",
2225
+ "consulting",
2226
+ "gmbh",
2227
+ "publishing",
2228
+ "company",
2229
+ "linkedin",
2230
+ "deutschland"
2231
+ ].includes(token));
2232
+ for (const token of looseTokens.slice(0, 5)) {
2233
+ keywords.add(token);
2234
+ }
2235
+ if (looseTokens.length > 1) {
2236
+ keywords.add(looseTokens.slice(0, 2).join(" "));
2237
+ keywords.add(looseTokens.slice(-2).join(" "));
2238
+ }
2239
+ }
2240
+ };
2241
+ addPhrase(contact.companyNameOriginal ?? contact.companyName);
2242
+ const linkedInHandle = normalizeLinkedInCompanyHandle(contact.linkedinCompanyUrl ?? "");
2243
+ if (linkedInHandle && !/^\d+$/.test(linkedInHandle)) {
2244
+ addPhrase(linkedInHandle.replace(/[-_]+/g, " "));
2245
+ }
2246
+ const normalizedEmail = normalizeLookupWhitespace(contact.email);
2247
+ const emailDomain = normalizedEmail && !isSyntheticLinkedInLookupEmail(normalizedEmail)
2248
+ ? normalizedEmail.split("@")[1] ?? ""
2249
+ : "";
2250
+ if (emailDomain) {
2251
+ const normalizedDomain = emailDomain.replace(/^www\./i, "");
2252
+ keywords.add(normalizedDomain);
2253
+ const host = normalizedDomain.split(".")[0] ?? "";
2254
+ if (host) {
2255
+ addPhrase(host.replace(/[-_]+/g, " "));
2256
+ }
2257
+ }
2258
+ const companyUrl = contact.linkedinCompanyUrl?.trim();
2259
+ if (companyUrl) {
2260
+ const cacheKey = companyUrl.replace(/\/$/, "");
2261
+ let cachedHints = linkedInCompanyHintCache.get(cacheKey);
2262
+ if (!cachedHints) {
2263
+ const signals = await fetchLinkedInCompanyPageSignals(companyUrl, timeoutMs);
2264
+ cachedHints = signals ? [...extractKeywordPhrases(signals.title), ...extractKeywordPhrases(signals.description)] : [];
2265
+ linkedInCompanyHintCache.set(cacheKey, cachedHints);
2266
+ }
2267
+ for (const hint of cachedHints) {
2268
+ addPhrase(hint);
2269
+ }
2270
+ }
2271
+ return {
2272
+ phrases: Array.from(phrases)
2273
+ .map((value) => normalizeLookupWhitespace(value))
2274
+ .filter((value) => value.length > 0),
2275
+ keywords: Array.from(keywords)
2276
+ .map((value) => normalizeLookupWhitespace(value))
2277
+ .filter((value) => value.length > 0)
2278
+ };
2279
+ }
2280
+ async function buildSerperLinkedInProfileQueries(contact, timeoutMs) {
2281
+ const fullName = normalizeLookupWhitespace(`${contact.firstName} ${contact.lastName}`);
2282
+ const title = normalizeLookupWhitespace(contact.jobTitle);
2283
+ const queryEntries = [];
2284
+ const seenQueries = new Set();
2285
+ const pushQuery = (query, score) => {
2286
+ const normalized = normalizeLookupWhitespace(query);
2287
+ if (!normalized) {
2288
+ return;
2289
+ }
2290
+ const key = normalized.toLowerCase();
2291
+ if (seenQueries.has(key)) {
2292
+ return;
2293
+ }
2294
+ seenQueries.add(key);
2295
+ queryEntries.push({ query: normalized, score });
2296
+ };
2297
+ const { phrases, keywords } = await buildLinkedInProfileCompanyHints(contact, timeoutMs);
2298
+ const enrichedPhrases = new Set(phrases);
2299
+ const enrichedKeywords = new Set(keywords);
2300
+ const preferredPhrases = [];
2301
+ const normalizedEmail = normalizeLookupWhitespace(contact.email);
2302
+ const trustedEmailDomain = normalizedEmail && !isSyntheticLinkedInLookupEmail(normalizedEmail)
2303
+ ? normalizedEmail.split("@")[1]?.replace(/^www\./i, "") ?? ""
2304
+ : "";
2305
+ const emailHost = trustedEmailDomain.split(".")[0] ?? "";
2306
+ const emailDomain = trustedEmailDomain;
2307
+ const linkedInHandle = normalizeLinkedInCompanyHandle(contact.linkedinCompanyUrl ?? "")?.replace(/[-_]+/g, " ") ?? "";
2308
+ if (contact.linkedinCompanyUrl?.trim()) {
2309
+ const companySignals = await fetchLinkedInCompanyPageSignals(contact.linkedinCompanyUrl.trim(), timeoutMs);
2310
+ for (const phrase of [
2311
+ ...extractKeywordPhrases(companySignals?.title),
2312
+ ...extractKeywordPhrases(companySignals?.description)
2313
+ ]) {
2314
+ enrichedPhrases.add(phrase);
2315
+ preferredPhrases.push(phrase);
2316
+ const looseTokens = normalizeLooseMatchText(phrase)
2317
+ .split(/\s+/)
2318
+ .filter((token) => token.length >= 4)
2319
+ .filter((token) => ![
2320
+ "group",
2321
+ "holding",
2322
+ "services",
2323
+ "service",
2324
+ "consulting",
2325
+ "gmbh",
2326
+ "publishing",
2327
+ "company",
2328
+ "linkedin",
2329
+ "deutschland"
2330
+ ].includes(token));
2331
+ for (const token of looseTokens.slice(0, 4)) {
2332
+ enrichedKeywords.add(token);
2333
+ }
2334
+ if (looseTokens.length > 1) {
2335
+ enrichedKeywords.add(looseTokens.slice(0, 2).join(" "));
2336
+ }
2337
+ }
2338
+ }
2339
+ const phrasePriority = (value) => {
2340
+ const loose = normalizeLooseMatchText(value);
2341
+ const tokenCount = loose.split(/\s+/).filter(Boolean).length;
2342
+ let score = 0;
2343
+ if (emailHost && loose.includes(normalizeLooseMatchText(emailHost)))
2344
+ score += 80;
2345
+ if (linkedInHandle && loose.includes(normalizeLooseMatchText(linkedInHandle)))
2346
+ score += 60;
2347
+ if (tokenCount >= 1 && tokenCount <= 4)
2348
+ score += 40;
2349
+ if (!/\b(gmbh|holding|services|service|consulting|kg|co)\b/i.test(value))
2350
+ score += 20;
2351
+ if (tokenCount > 7)
2352
+ score -= 40;
2353
+ return score;
2354
+ };
2355
+ const keywordPriority = (value) => {
2356
+ const loose = normalizeLooseMatchText(value);
2357
+ let score = 0;
2358
+ if (emailHost && loose.includes(normalizeLooseMatchText(emailHost)))
2359
+ score += 80;
2360
+ if (linkedInHandle && loose.includes(normalizeLooseMatchText(linkedInHandle)))
2361
+ score += 60;
2362
+ if (value.includes("."))
2363
+ score += 20;
2364
+ if (loose.split(/\s+/).filter(Boolean).length <= 2)
2365
+ score += 10;
2366
+ return score;
2367
+ };
2368
+ const rankedPhrases = [...enrichedPhrases].sort((left, right) => {
2369
+ const preferredDelta = Number(preferredPhrases.includes(right)) - Number(preferredPhrases.includes(left));
2370
+ if (preferredDelta !== 0) {
2371
+ return preferredDelta;
2372
+ }
2373
+ return phrasePriority(right) - phrasePriority(left);
2374
+ });
2375
+ const cleanPhrases = rankedPhrases.slice(0, 6);
2376
+ const fallbackKeywords = new Set(enrichedKeywords);
2377
+ for (const phrase of cleanPhrases) {
2378
+ const looseTokens = normalizeLooseMatchText(phrase)
2379
+ .split(/\s+/)
2380
+ .filter((token) => token.length >= 4)
2381
+ .filter((token) => ![
2382
+ "group",
2383
+ "holding",
2384
+ "services",
2385
+ "service",
2386
+ "consulting",
2387
+ "gmbh",
2388
+ "publishing",
2389
+ "company",
2390
+ "linkedin",
2391
+ "deutschland"
2392
+ ].includes(token));
2393
+ for (const token of looseTokens.slice(0, 3)) {
2394
+ fallbackKeywords.add(token);
2395
+ }
2396
+ if (looseTokens.length > 1) {
2397
+ fallbackKeywords.add(looseTokens.slice(0, 2).join(" "));
2398
+ }
2399
+ }
2400
+ if (emailHost) {
2401
+ fallbackKeywords.add(emailHost);
2402
+ }
2403
+ if (emailDomain) {
2404
+ fallbackKeywords.add(emailDomain);
2405
+ }
2406
+ if (linkedInHandle) {
2407
+ fallbackKeywords.add(linkedInHandle);
2408
+ }
2409
+ const cleanKeywords = [...fallbackKeywords]
2410
+ .sort((left, right) => keywordPriority(right) - keywordPriority(left))
2411
+ .slice(0, 5);
2412
+ cleanKeywords.forEach((keyword, index) => {
2413
+ const keywordScore = 260 - index * 15;
2414
+ pushQuery(`site:linkedin.com/in "${fullName}" ${keyword} linkedin`, keywordScore);
2415
+ pushQuery(`site:linkedin.com/in ${fullName} ${keyword} linkedin`, keywordScore - 5);
2416
+ if (title) {
2417
+ pushQuery(`site:linkedin.com/in "${fullName}" ${keyword} "${title}"`, keywordScore - 10);
2418
+ }
2419
+ });
2420
+ cleanPhrases.forEach((companyName, index) => {
2421
+ const phraseScore = 180 - index * 10;
2422
+ pushQuery(`site:linkedin.com/in "${fullName}" "${companyName}"`, phraseScore);
2423
+ pushQuery(`site:linkedin.com/in ${fullName} ${companyName} linkedin`, phraseScore - 5);
2424
+ if (title) {
2425
+ pushQuery(`site:linkedin.com/in "${fullName}" "${companyName}" "${title}"`, phraseScore - 10);
2426
+ pushQuery(`site:linkedin.com/in ${fullName} ${companyName} ${title} linkedin`, phraseScore - 15);
2427
+ }
2428
+ });
2429
+ if (emailDomain) {
2430
+ pushQuery(`site:linkedin.com/in "${fullName}" "${emailDomain}" linkedin`, 240);
2431
+ }
2432
+ pushQuery(`site:linkedin.com/in "${fullName}" linkedin`, 50);
2433
+ if (title) {
2434
+ pushQuery(`site:linkedin.com/in "${fullName}" "${title}" linkedin`, 40);
2435
+ }
2436
+ return queryEntries
2437
+ .sort((left, right) => right.score - left.score)
2438
+ .map((entry) => entry.query);
2439
+ }
2440
+ function extractPublicLinkedInProfileSearchCandidates(bodyText) {
2441
+ const candidates = new Set();
2442
+ const directMatches = bodyText.match(/https:\/\/(?:(?:www|[a-z]{2})\.)?linkedin\.com\/in\/[^"'&<>\s)]+/gi) ?? [];
2443
+ for (const match of directMatches) {
2444
+ const normalized = normalizePublicLinkedInProfileUrl(match);
2445
+ if (normalized) {
2446
+ candidates.add(normalized);
2447
+ }
2448
+ }
2449
+ const encodedMatches = bodyText.match(/https?%3A%2F%2F(?:(?:www|[a-z]{2})\.)?linkedin\.com%2Fin%2F[^"'&<>\s)]+/gi) ?? [];
2450
+ for (const match of encodedMatches) {
2451
+ try {
2452
+ const decoded = decodeURIComponent(match);
2453
+ const normalized = normalizePublicLinkedInProfileUrl(decoded);
2454
+ if (normalized) {
2455
+ candidates.add(normalized);
2456
+ }
2457
+ }
2458
+ catch {
2459
+ // Ignore malformed encoded fragments.
2460
+ }
2461
+ }
2462
+ return Array.from(candidates);
2463
+ }
2464
+ function buildPublicLinkedInProfileSearchUrl(query) {
2465
+ const baseUrl = process.env.SALESPROMPTER_LINKEDIN_PROFILE_SEARCH_BASE_URL?.trim() ||
2466
+ "https://duckduckgo.com/html/";
2467
+ const url = new URL(baseUrl);
2468
+ url.searchParams.set("q", query);
2469
+ return url.toString();
2470
+ }
2471
+ async function fetchSerperSearchResults(query, num, timeoutMs) {
2472
+ if (serperCreditsExhausted) {
2473
+ return null;
2474
+ }
2475
+ const apiKey = getSerperApiKey();
2476
+ if (!apiKey) {
2477
+ return null;
2478
+ }
2479
+ const cacheKey = `${query}::${num}`;
2480
+ if (serperSearchCache.has(cacheKey)) {
2481
+ return serperSearchCache.get(cacheKey) ?? null;
2482
+ }
2483
+ const controller = new AbortController();
2484
+ const timeout = setTimeout(() => controller.abort(), Math.min(timeoutMs, 12_000));
2485
+ try {
2486
+ const response = await fetch(getSerperSearchEndpoint(), {
2487
+ method: "POST",
2488
+ signal: controller.signal,
2489
+ headers: {
2490
+ "X-API-KEY": apiKey,
2491
+ "Content-Type": "application/json"
2492
+ },
2493
+ body: JSON.stringify({ q: query, num })
2494
+ });
2495
+ if (!response.ok) {
2496
+ const bodyText = await response.text().catch(() => "");
2497
+ if (response.status === 400 &&
2498
+ /not enough credits/i.test(bodyText)) {
2499
+ serperCreditsExhausted = true;
2500
+ }
2501
+ serperSearchCache.set(cacheKey, null);
2502
+ return null;
2503
+ }
2504
+ const parsed = await response.json();
2505
+ serperSearchCache.set(cacheKey, parsed);
2506
+ return parsed;
2507
+ }
2508
+ catch {
2509
+ return null;
2510
+ }
2511
+ finally {
2512
+ clearTimeout(timeout);
2513
+ }
2514
+ }
2515
+ function extractSerperLinkedInProfileCandidates(payload) {
2516
+ if (!payload || typeof payload !== "object") {
2517
+ return [];
2518
+ }
2519
+ const organic = "organic" in payload && Array.isArray(payload.organic)
2520
+ ? (payload.organic ?? [])
2521
+ : [];
2522
+ const seen = new Set();
2523
+ const candidates = [];
2524
+ for (const result of organic) {
2525
+ if (!result || typeof result !== "object")
2526
+ continue;
2527
+ const link = "link" in result && typeof result.link === "string"
2528
+ ? result.link
2529
+ : "";
2530
+ const normalized = normalizePublicLinkedInProfileUrl(link);
2531
+ if (normalized) {
2532
+ const canonical = normalized.replace(/\/$/, "");
2533
+ if (!seen.has(canonical)) {
2534
+ seen.add(canonical);
2535
+ candidates.push({
2536
+ url: canonical,
2537
+ title: "title" in result && typeof result.title === "string"
2538
+ ? normalizeLookupWhitespace(result.title)
2539
+ : "",
2540
+ snippet: "snippet" in result && typeof result.snippet === "string"
2541
+ ? normalizeLookupWhitespace(result.snippet)
2542
+ : ""
2543
+ });
2544
+ }
2545
+ }
2546
+ }
2547
+ return candidates;
2548
+ }
2549
+ async function fetchLinkedInProfilePageSignals(url, timeoutMs) {
2550
+ const cacheKey = normalizePublicLinkedInProfileUrl(url)?.replace(/\/$/, "") ?? url.replace(/\/$/, "");
2551
+ if (linkedInProfilePageSignalCache.has(cacheKey)) {
2552
+ return linkedInProfilePageSignalCache.get(cacheKey) ?? null;
2553
+ }
2554
+ const controller = new AbortController();
2555
+ const timeout = setTimeout(() => controller.abort(), Math.min(timeoutMs, 12_000));
2556
+ try {
2557
+ const targetUrl = rewriteLinkedInUrlForConfiguredBase(url);
2558
+ const response = await fetch(targetUrl, {
2559
+ method: "GET",
2560
+ signal: controller.signal,
2561
+ headers: {
2562
+ "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"
2563
+ }
2564
+ });
2565
+ const html = await response.text();
2566
+ const finalUrl = normalizePublicLinkedInProfileUrl(url) ||
2567
+ normalizePublicLinkedInProfileUrl(response.url || url);
2568
+ if (!finalUrl) {
2569
+ return null;
2570
+ }
2571
+ const title = decodeHtmlEntities(html.match(/<title[^>]*>([^<]+)/i)?.[1] || "") ||
2572
+ decodeHtmlEntities(html.match(/<meta[^>]+property="og:title"[^>]+content="([^"]+)/i)?.[1] || "");
2573
+ const description = decodeHtmlEntities(html.match(/<meta[^>]+name="description"[^>]+content="([^"]+)/i)?.[1] || "");
2574
+ const bodyText = decodeHtmlEntities(html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " "));
2575
+ const unavailableText = normalizeLooseMatchText(`${title} ${description} ${bodyText}`);
2576
+ const unavailable = response.status >= 400 ||
2577
+ unavailableText.includes("page not found") ||
2578
+ unavailableText.includes("profile not found") ||
2579
+ unavailableText.includes("member profile") && unavailableText.includes("not available");
2580
+ const result = {
2581
+ normalizedUrl: finalUrl.replace(/\/$/, ""),
2582
+ title: normalizeLookupWhitespace(title),
2583
+ description: normalizeLookupWhitespace(description),
2584
+ bodyText: normalizeLookupWhitespace(bodyText),
2585
+ unavailable
2586
+ };
2587
+ linkedInProfilePageSignalCache.set(cacheKey, result);
2588
+ return result;
2589
+ }
2590
+ catch {
2591
+ linkedInProfilePageSignalCache.set(cacheKey, null);
2592
+ return null;
2593
+ }
2594
+ finally {
2595
+ clearTimeout(timeout);
2596
+ }
2597
+ }
2598
+ function scoreLinkedInProfilePageSignals(contact, signals) {
2599
+ const fullName = normalizeLooseMatchText(`${contact.firstName} ${contact.lastName}`);
2600
+ const companyHints = [
2601
+ normalizeLooseMatchText(contact.companyNameOriginal ?? contact.companyName),
2602
+ normalizeLooseMatchText(aggressivelyCleanLookupCompanyName(contact.companyNameOriginal ?? contact.companyName))
2603
+ ].filter(Boolean);
2604
+ const titleHint = normalizeLooseMatchText(contact.jobTitle);
2605
+ const haystack = normalizeLooseMatchText(`${signals.title} ${signals.description} ${signals.bodyText}`);
2606
+ let score = 0;
2607
+ if (fullName && haystack.includes(fullName))
2608
+ score += 120;
2609
+ for (const hint of companyHints) {
2610
+ if (hint && haystack.includes(hint))
2611
+ score += 30;
2612
+ }
2613
+ if (titleHint) {
2614
+ const titleWords = titleHint.split(/\s+/).filter((token) => token.length >= 4).slice(0, 4);
2615
+ score += titleWords.filter((token) => haystack.includes(token)).length * 8;
2616
+ }
2617
+ const slug = signals.normalizedUrl.split("/in/")[1]?.replace(/\/$/, "") ?? "";
2618
+ const slugText = normalizeLooseMatchText(slug.replace(/[-_]+/g, " "));
2619
+ if (fullName && slugText.includes(contact.firstName.toLowerCase()) && slugText.includes(contact.lastName.toLowerCase())) {
2620
+ score += 40;
2621
+ }
2622
+ return score;
2623
+ }
2624
+ function analyzeSerperLinkedInProfileCandidate(contact, candidate) {
2625
+ const fullName = normalizeLooseMatchText(`${contact.firstName} ${contact.lastName}`);
2626
+ const titleHint = normalizeLooseMatchText(contact.jobTitle);
2627
+ const companyTokens = [
2628
+ normalizeLooseMatchText(contact.companyNameOriginal ?? contact.companyName),
2629
+ normalizeLooseMatchText(aggressivelyCleanLookupCompanyName(contact.companyNameOriginal ?? contact.companyName)),
2630
+ normalizeLooseMatchText(normalizeLinkedInCompanyHandle(contact.linkedinCompanyUrl ?? "")?.replace(/[-_]+/g, " ") ?? ""),
2631
+ normalizeLooseMatchText((() => {
2632
+ const normalizedEmail = normalizeLookupWhitespace(contact.email);
2633
+ if (!normalizedEmail || isSyntheticLinkedInLookupEmail(normalizedEmail)) {
2634
+ return "";
2635
+ }
2636
+ return normalizedEmail.split("@")[1]?.replace(/^www\./i, "").split(".")[0] ?? "";
2637
+ })())
2638
+ ].filter(Boolean);
2639
+ const haystack = normalizeLooseMatchText(`${candidate.title} ${candidate.snippet}`);
2640
+ let score = 0;
2641
+ let companyMatches = 0;
2642
+ let titleMatches = 0;
2643
+ if (fullName && haystack.includes(fullName))
2644
+ score += 120;
2645
+ for (const token of companyTokens) {
2646
+ if (!token)
2647
+ continue;
2648
+ if (haystack.includes(token)) {
2649
+ companyMatches += 1;
2650
+ score += token.split(/\s+/).length <= 2 ? 30 : 20;
2651
+ }
2652
+ }
2653
+ if (titleHint) {
2654
+ const titleWords = titleHint.split(/\s+/).filter((token) => token.length >= 4).slice(0, 4);
2655
+ titleMatches = titleWords.filter((token) => haystack.includes(token)).length;
2656
+ score += titleMatches * 8;
2657
+ }
2658
+ const slugText = normalizeLooseMatchText(candidate.url.split("/in/")[1]?.replace(/\/$/, "").replace(/[-_]+/g, " ") ?? "");
2659
+ if (fullName &&
2660
+ slugText.includes(contact.firstName.toLowerCase()) &&
2661
+ slugText.includes(contact.lastName.toLowerCase()) &&
2662
+ (companyMatches > 0 || titleMatches > 0)) {
2663
+ score += 40;
2664
+ }
2665
+ return { score, companyMatches, titleMatches };
2666
+ }
2667
+ async function searchSerperLinkedInProfileUrl(contact, timeoutMs, options) {
2668
+ if (!contact.firstName || !contact.lastName) {
2669
+ return null;
2670
+ }
2671
+ const maxQueries = options?.maxQueries && Number.isFinite(options.maxQueries) && options.maxQueries > 0
2672
+ ? Math.trunc(options.maxQueries)
2673
+ : Number.POSITIVE_INFINITY;
2674
+ for (const query of (await buildSerperLinkedInProfileQueries(contact, timeoutMs)).slice(0, maxQueries)) {
2675
+ try {
2676
+ const parsed = await fetchSerperSearchResults(query, 5, timeoutMs);
2677
+ if (!parsed) {
2678
+ continue;
2679
+ }
2680
+ const candidates = extractSerperLinkedInProfileCandidates(parsed);
2681
+ let bestUrl = null;
2682
+ let bestScore = 0;
2683
+ for (const candidate of candidates) {
2684
+ const serperAnalysis = analyzeSerperLinkedInProfileCandidate(contact, candidate);
2685
+ const serperScore = serperAnalysis.score;
2686
+ if (serperScore >= 150 && (serperAnalysis.companyMatches > 0 || serperAnalysis.titleMatches > 0)) {
2687
+ return candidate.url;
2688
+ }
2689
+ const signals = await fetchLinkedInProfilePageSignals(candidate.url, timeoutMs);
2690
+ if (!signals || signals.unavailable) {
2691
+ if (serperScore > bestScore) {
2692
+ bestScore = serperScore;
2693
+ bestUrl = candidate.url;
2694
+ }
2695
+ continue;
2696
+ }
2697
+ const score = Math.max(serperScore, scoreLinkedInProfilePageSignals(contact, signals));
2698
+ if (score > bestScore) {
2699
+ bestScore = score;
2700
+ bestUrl = signals.normalizedUrl;
2701
+ }
2702
+ }
2703
+ if (bestUrl && bestScore >= 130) {
2704
+ return bestUrl;
2705
+ }
2706
+ }
2707
+ catch {
2708
+ // Continue with the next query variant.
2709
+ }
2710
+ }
2711
+ return searchPublicLinkedInProfileUrl(contact, timeoutMs, {
2712
+ maxQueries: Math.min(Number.isFinite(maxQueries) ? maxQueries : 4, 4)
2713
+ });
2714
+ }
2715
+ function decodeHtmlEntities(value) {
2716
+ return value
2717
+ .replace(/&amp;/gi, "&")
2718
+ .replace(/&quot;/gi, '"')
2719
+ .replace(/&#39;/gi, "'")
2720
+ .replace(/&lt;/gi, "<")
2721
+ .replace(/&gt;/gi, ">");
2722
+ }
2723
+ async function fetchLinkedInCompanyPageSignals(url, timeoutMs) {
2724
+ const cacheKey = url.replace(/\/$/, "");
2725
+ if (linkedInCompanyPageSignalCache.has(cacheKey)) {
2726
+ return linkedInCompanyPageSignalCache.get(cacheKey) ?? null;
2727
+ }
2728
+ const controller = new AbortController();
2729
+ const timeout = setTimeout(() => controller.abort(), Math.min(timeoutMs, 12_000));
2730
+ try {
2731
+ const response = await fetch(url, {
2732
+ method: "GET",
2733
+ signal: controller.signal,
2734
+ headers: {
2735
+ "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"
2736
+ }
2737
+ });
2738
+ const html = await response.text();
2739
+ const finalUrl = response.url || url;
2740
+ const title = decodeHtmlEntities(html.match(/<title[^>]*>([^<]+)/i)?.[1] || "") ||
2741
+ decodeHtmlEntities(html.match(/<meta[^>]+property="og:title"[^>]+content="([^"]+)/i)?.[1] || "");
2742
+ const description = decodeHtmlEntities(html.match(/<meta[^>]+name="description"[^>]+content="([^"]+)/i)?.[1] || "");
2743
+ const bodyText = decodeHtmlEntities(html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " "));
2744
+ const unavailableText = normalizeLooseMatchText(`${title} ${description} ${bodyText}`);
2745
+ const unavailable = response.status >= 400 ||
2746
+ unavailableText.includes("page not found") ||
2747
+ unavailableText.includes("this page does not exist") ||
2748
+ unavailableText.includes("page isnt available");
2749
+ const result = {
2750
+ normalizedUrl: normalizeLinkedInCompanyHandle(finalUrl ?? "") || normalizeLinkedInCompanyHandle(url)
2751
+ ? normalizeLinkedInCompanyPage(normalizeLinkedInCompanyHandle(finalUrl ?? "") ?? normalizeLinkedInCompanyHandle(url) ?? "")
2752
+ : finalUrl,
1613
2753
  title: normalizeLookupWhitespace(title),
1614
2754
  description: normalizeLookupWhitespace(description),
1615
2755
  bodyText: normalizeLookupWhitespace(bodyText),
1616
2756
  unavailable
1617
2757
  };
2758
+ linkedInCompanyPageSignalCache.set(cacheKey, result);
2759
+ return result;
1618
2760
  }
1619
2761
  catch {
2762
+ linkedInCompanyPageSignalCache.set(cacheKey, null);
1620
2763
  return null;
1621
2764
  }
1622
2765
  finally {
1623
2766
  clearTimeout(timeout);
1624
2767
  }
1625
2768
  }
2769
+ async function searchPublicLinkedInProfileUrl(contact, timeoutMs, options) {
2770
+ const maxQueries = options?.maxQueries && Number.isFinite(options.maxQueries) && options.maxQueries > 0
2771
+ ? Math.trunc(options.maxQueries)
2772
+ : 4;
2773
+ const queries = (await buildSerperLinkedInProfileQueries(contact, timeoutMs)).slice(0, maxQueries);
2774
+ for (const query of queries) {
2775
+ const controller = new AbortController();
2776
+ const timeout = setTimeout(() => controller.abort(), Math.min(timeoutMs, 12_000));
2777
+ try {
2778
+ const response = await fetch(buildPublicLinkedInProfileSearchUrl(query), {
2779
+ method: "GET",
2780
+ signal: controller.signal,
2781
+ headers: {
2782
+ "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"
2783
+ }
2784
+ });
2785
+ if (!response.ok) {
2786
+ continue;
2787
+ }
2788
+ const bodyText = await response.text();
2789
+ const candidates = extractPublicLinkedInProfileSearchCandidates(bodyText);
2790
+ let bestUrl = null;
2791
+ let bestScore = 0;
2792
+ for (const candidateUrl of candidates.slice(0, 5)) {
2793
+ const signals = await fetchLinkedInProfilePageSignals(candidateUrl, timeoutMs);
2794
+ if (!signals || signals.unavailable) {
2795
+ continue;
2796
+ }
2797
+ const score = scoreLinkedInProfilePageSignals(contact, signals);
2798
+ if (score > bestScore) {
2799
+ bestScore = score;
2800
+ bestUrl = signals.normalizedUrl;
2801
+ }
2802
+ }
2803
+ if (bestUrl && bestScore >= 130) {
2804
+ return bestUrl;
2805
+ }
2806
+ }
2807
+ catch {
2808
+ // Continue with the next query variant.
2809
+ }
2810
+ finally {
2811
+ clearTimeout(timeout);
2812
+ }
2813
+ }
2814
+ return null;
2815
+ }
1626
2816
  function scoreLinkedInCompanyPageSignals(companyName, signals) {
1627
2817
  const inputTokens = normalizeLooseMatchText(companyName).split(/\s+/).filter((token) => token.length >= 4);
1628
2818
  const haystack = normalizeLooseMatchText(`${signals.title} ${signals.description}`);
@@ -1637,6 +2827,20 @@ function scoreLinkedInCompanyPageSignals(companyName, signals) {
1637
2827
  }
1638
2828
  return score;
1639
2829
  }
2830
+ function scoreSerperLinkedInCompanyCandidate(companyName, candidate) {
2831
+ const inputTokens = normalizeLooseMatchText(companyName).split(/\s+/).filter((token) => token.length >= 4);
2832
+ const haystack = normalizeLooseMatchText(`${candidate.title} ${candidate.snippet}`);
2833
+ let score = scoreLinkedInCompanyUrlCandidate(companyName, candidate.url);
2834
+ for (const token of inputTokens) {
2835
+ if (haystack.includes(token)) {
2836
+ score += 12;
2837
+ }
2838
+ }
2839
+ if (haystack.includes(normalizeLooseMatchText(aggressivelyCleanLookupCompanyName(companyName)))) {
2840
+ score += 40;
2841
+ }
2842
+ return score;
2843
+ }
1640
2844
  function scoreLinkedInCompanyUrlCandidate(companyName, url) {
1641
2845
  const handle = normalizeLinkedInCompanyHandle(url);
1642
2846
  if (!handle || /^\d+$/.test(handle)) {
@@ -1730,9 +2934,15 @@ async function searchSerperLinkedInCompanyUrl(companyName, timeoutMs) {
1730
2934
  const parsed = (await response.json());
1731
2935
  const candidates = extractSerperLinkedInCompanyCandidates(parsed);
1732
2936
  const ranked = candidates
1733
- .map((url) => ({ url, score: scoreLinkedInCompanyUrlCandidate(companyName, url) }))
2937
+ .map((candidate) => ({
2938
+ ...candidate,
2939
+ score: scoreSerperLinkedInCompanyCandidate(companyName, candidate)
2940
+ }))
1734
2941
  .filter((candidate) => candidate.score > 0)
1735
2942
  .sort((left, right) => right.score - left.score);
2943
+ if (ranked[0] && ranked[0].score >= 80) {
2944
+ return ranked[0].url;
2945
+ }
1736
2946
  let anySignalsFetched = false;
1737
2947
  let bestValidated = null;
1738
2948
  for (const candidate of ranked.slice(0, 3)) {
@@ -1772,6 +2982,11 @@ async function searchSerperLinkedInCompanyUrl(companyName, timeoutMs) {
1772
2982
  }
1773
2983
  async function invokeLinkedInUrlEnrichmentDirect(params) {
1774
2984
  const config = await readLinkedInDirectLookupConfig();
2985
+ const companyContexts = await resolveDirectLinkedInCompanyContexts({
2986
+ contacts: params.contacts.filter((contact) => !contact.isVariation),
2987
+ timeoutMs: params.timeoutMs,
2988
+ config
2989
+ });
1775
2990
  const groupedContacts = new Map();
1776
2991
  for (const contact of params.contacts) {
1777
2992
  const key = contact.email?.trim().toLowerCase() || `contact:${contact.contact_id}`;
@@ -1780,15 +2995,25 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
1780
2995
  groupedContacts.set(key, existing);
1781
2996
  }
1782
2997
  const results = [];
1783
- let rateLimited = false;
2998
+ const perAttemptTimeoutMs = params.perAttemptTimeoutMs && Number.isFinite(params.perAttemptTimeoutMs) && params.perAttemptTimeoutMs > 0
2999
+ ? Math.trunc(params.perAttemptTimeoutMs)
3000
+ : Math.min(params.timeoutMs, 8_000);
3001
+ const perContactBudgetMs = params.perContactBudgetMs && Number.isFinite(params.perContactBudgetMs) && params.perContactBudgetMs > 0
3002
+ ? Math.trunc(params.perContactBudgetMs)
3003
+ : Math.min(params.timeoutMs, 15_000);
3004
+ const rateLimitCooldownMs = Math.max(750, Math.min(3_000, Math.trunc(perAttemptTimeoutMs / 2)));
3005
+ const maxRateLimitCooldowns = 4;
3006
+ let rateLimitCooldownUntil = 0;
3007
+ let consecutiveRateLimitCount = 0;
3008
+ let totalRateLimitCooldowns = 0;
1784
3009
  for (const variations of groupedContacts.values()) {
1785
3010
  const primary = variations.find((contact) => !contact.isVariation) ?? variations[0];
1786
3011
  const blankPerson = !primary?.firstName.trim() || !primary?.lastName.trim();
1787
- if (rateLimited) {
3012
+ if (totalRateLimitCooldowns >= maxRateLimitCooldowns) {
1788
3013
  results.push({
1789
3014
  contact_id: primary.contact_id,
1790
3015
  linkedin_url: null,
1791
- error: "LinkedIn rate limit"
3016
+ error: "LinkedIn rate limit budget exhausted"
1792
3017
  });
1793
3018
  continue;
1794
3019
  }
@@ -1802,11 +3027,23 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
1802
3027
  }
1803
3028
  let matchedUrl = null;
1804
3029
  let matchedSalesNavUrl = null;
3030
+ let matchedFullName = null;
3031
+ let matchedCompanyName = null;
3032
+ let matchedTitle = null;
1805
3033
  let lastError = null;
3034
+ const contactDeadline = Date.now() + perContactBudgetMs;
3035
+ const companyContext = companyContexts.get(buildDirectCompanyContextKey(primary));
1806
3036
  for (const candidate of variations) {
1807
- for (const searchVariant of buildLinkedInLookupSearchVariants(candidate)) {
3037
+ for (const searchVariant of await buildLinkedInLookupSearchVariants(candidate, params.timeoutMs, companyContext?.aliases ?? [])) {
3038
+ if (Date.now() < rateLimitCooldownUntil) {
3039
+ await new Promise((resolve) => setTimeout(resolve, rateLimitCooldownUntil - Date.now()));
3040
+ }
3041
+ if (Date.now() >= contactDeadline) {
3042
+ lastError = lastError || "Direct lookup budget exhausted";
3043
+ break;
3044
+ }
1808
3045
  const controller = new AbortController();
1809
- const timeout = setTimeout(controller.abort.bind(controller), Math.min(params.timeoutMs, 20_000));
3046
+ const timeout = setTimeout(controller.abort.bind(controller), Math.min(perAttemptTimeoutMs, Math.max(1_000, contactDeadline - Date.now())));
1810
3047
  try {
1811
3048
  const response = await fetch(buildLinkedInSalesApiUrl(searchVariant), {
1812
3049
  method: "GET",
@@ -1827,20 +3064,51 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
1827
3064
  }
1828
3065
  });
1829
3066
  if (response.status === 429) {
1830
- rateLimited = true;
1831
3067
  lastError = "LinkedIn rate limit";
3068
+ consecutiveRateLimitCount += 1;
3069
+ totalRateLimitCooldowns += 1;
3070
+ rateLimitCooldownUntil =
3071
+ Date.now() + Math.min(15_000, rateLimitCooldownMs * Math.max(1, consecutiveRateLimitCount));
3072
+ if (totalRateLimitCooldowns >= maxRateLimitCooldowns) {
3073
+ break;
3074
+ }
1832
3075
  break;
1833
3076
  }
1834
3077
  if (!response.ok) {
1835
3078
  lastError = `LinkedIn returned ${response.status}`;
1836
3079
  continue;
1837
3080
  }
3081
+ consecutiveRateLimitCount = 0;
3082
+ rateLimitCooldownUntil = 0;
1838
3083
  const data = (await response.json());
1839
3084
  const profilesFound = data.paging?.total ?? 0;
1840
3085
  if (profilesFound > 0) {
1841
- const first = data.elements?.[0];
1842
- matchedUrl = extractLinkedInProfileUrlFromSalesApiElement(first) ?? null;
1843
- matchedSalesNavUrl = extractLinkedInSalesNavLeadUrlFromSalesApiElement(first) ?? null;
3086
+ const bestCandidate = (data.elements ?? [])
3087
+ .map((element) => ({
3088
+ element,
3089
+ ...scoreLinkedInSalesApiElementMatch(candidate, element)
3090
+ }))
3091
+ .sort((left, right) => right.score - left.score)[0];
3092
+ const hasTrustedCompanyContext = Boolean(candidate.linkedinCompanyUrl ||
3093
+ companyContext?.linkedinCompanyUrl ||
3094
+ companyContext?.matchedCompanyName);
3095
+ const hasTrustedEmailContext = Boolean(candidate.email && !isSyntheticLinkedInLookupEmail(candidate.email));
3096
+ const acceptBestCandidate = Boolean(bestCandidate &&
3097
+ (bestCandidate.score >= 140 ||
3098
+ (bestCandidate.exactNameMatch &&
3099
+ (bestCandidate.companyMatchCount > 0 || hasTrustedCompanyContext || hasTrustedEmailContext))));
3100
+ if (bestCandidate && acceptBestCandidate) {
3101
+ matchedUrl = extractLinkedInProfileUrlFromSalesApiElement(bestCandidate.element) ?? null;
3102
+ matchedSalesNavUrl = extractLinkedInSalesNavLeadUrlFromSalesApiElement(bestCandidate.element) ?? null;
3103
+ matchedFullName = bestCandidate.fullName;
3104
+ matchedCompanyName = bestCandidate.companyName;
3105
+ matchedTitle = bestCandidate.title;
3106
+ }
3107
+ else {
3108
+ lastError = bestCandidate
3109
+ ? `LinkedIn top result score too low (${bestCandidate.score})`
3110
+ : "LinkedIn returned no usable results";
3111
+ }
1844
3112
  if (matchedUrl || matchedSalesNavUrl) {
1845
3113
  break;
1846
3114
  }
@@ -1852,11 +3120,14 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
1852
3120
  finally {
1853
3121
  clearTimeout(timeout);
1854
3122
  }
1855
- if (matchedUrl || matchedSalesNavUrl || rateLimited) {
3123
+ if (matchedUrl || matchedSalesNavUrl || totalRateLimitCooldowns >= maxRateLimitCooldowns) {
1856
3124
  break;
1857
3125
  }
1858
3126
  }
1859
- if (matchedUrl || matchedSalesNavUrl || rateLimited) {
3127
+ if (matchedUrl || matchedSalesNavUrl || totalRateLimitCooldowns >= maxRateLimitCooldowns) {
3128
+ break;
3129
+ }
3130
+ if (Date.now() >= contactDeadline) {
1860
3131
  break;
1861
3132
  }
1862
3133
  }
@@ -1864,16 +3135,21 @@ async function invokeLinkedInUrlEnrichmentDirect(params) {
1864
3135
  contact_id: primary.contact_id,
1865
3136
  linkedin_url: matchedUrl ?? matchedSalesNavUrl,
1866
3137
  sales_nav_profile_url: matchedSalesNavUrl,
3138
+ matched_full_name: matchedFullName,
3139
+ matched_company_name: matchedCompanyName,
3140
+ matched_title: matchedTitle,
1867
3141
  error: matchedUrl || matchedSalesNavUrl ? null : lastError
1868
3142
  });
1869
3143
  }
1870
3144
  return {
1871
3145
  success: true,
1872
- contacts: results
3146
+ contacts: results,
3147
+ companyContexts: Array.from(companyContexts.values())
1873
3148
  };
1874
3149
  }
1875
3150
  async function invokeLinkedInCompanyEnrichmentDirect(params) {
1876
3151
  const config = await readLinkedInDirectLookupConfig();
3152
+ const precomputedContextByKey = new Map((params.precomputedContexts ?? []).map((context) => [context.normalizedCompanyKey, context]));
1877
3153
  const primaryContacts = new Map();
1878
3154
  for (const contact of params.contacts) {
1879
3155
  const existing = primaryContacts.get(contact.contact_id);
@@ -1897,11 +3173,23 @@ async function invokeLinkedInCompanyEnrichmentDirect(params) {
1897
3173
  companyName: contact.companyName,
1898
3174
  companyNameOriginal: contact.companyNameOriginal
1899
3175
  });
1900
- let matchedCompanyUrl = null;
1901
- let matchedSalesNavCompanyUrl = null;
1902
- let matchedCompanyName = null;
1903
- let matchedCompanyEmployeeCount = null;
3176
+ const precomputedContext = precomputedContextByKey.get(buildDirectCompanyContextKey(contact));
3177
+ let matchedCompanyUrl = precomputedContext?.linkedinCompanyUrl ?? null;
3178
+ let matchedSalesNavCompanyUrl = precomputedContext?.salesNavCompanyUrl ?? null;
3179
+ let matchedCompanyName = precomputedContext?.matchedCompanyName ?? null;
3180
+ let matchedCompanyEmployeeCount = precomputedContext?.matchedCompanyEmployeeCount ?? null;
1904
3181
  let lastError = null;
3182
+ if (matchedCompanyUrl || matchedSalesNavCompanyUrl || matchedCompanyName) {
3183
+ results.push({
3184
+ contact_id: contact.contact_id,
3185
+ linkedin_company_url: matchedCompanyUrl,
3186
+ sales_nav_company_url: matchedSalesNavCompanyUrl,
3187
+ matched_company_name: matchedCompanyName,
3188
+ matched_company_employee_count: matchedCompanyEmployeeCount,
3189
+ error: null
3190
+ });
3191
+ continue;
3192
+ }
1905
3193
  for (const variant of variants) {
1906
3194
  const controller = new AbortController();
1907
3195
  const timeout = setTimeout(controller.abort.bind(controller), Math.min(params.timeoutMs, 20_000));
@@ -2044,9 +3332,34 @@ async function invokeLinkedInUrlEnrichmentWorkflow(params) {
2044
3332
  }
2045
3333
  }
2046
3334
  function normalizeWorkflowLinkedInUrlResult(params) {
3335
+ const inputContactIds = new Set(params.contacts.map((contact) => contact.contact_id));
2047
3336
  const contactIdsBySyntheticEmail = new Map(params.contacts
2048
3337
  .filter((contact) => contact.email)
2049
3338
  .map((contact) => [String(contact.email).toLowerCase(), contact.contact_id]));
3339
+ const contactIdsByNormalizedIdentity = new Map(params.contacts
3340
+ .filter((contact) => !contact.isVariation)
3341
+ .map((contact) => {
3342
+ const fullName = normalizeLooseMatchText(normalizeLookupWhitespace(`${contact.firstName} ${contact.lastName}`));
3343
+ const companyName = normalizeLooseMatchText(contact.companyNameOriginal ?? contact.companyName);
3344
+ return [`${fullName}|${companyName}`, contact.contact_id];
3345
+ })
3346
+ .filter(([key]) => key !== "|"));
3347
+ const normalizedNameCounts = new Map();
3348
+ for (const contact of params.contacts) {
3349
+ if (contact.isVariation)
3350
+ continue;
3351
+ const fullName = normalizeLooseMatchText(normalizeLookupWhitespace(`${contact.firstName} ${contact.lastName}`));
3352
+ if (!fullName)
3353
+ continue;
3354
+ normalizedNameCounts.set(fullName, (normalizedNameCounts.get(fullName) ?? 0) + 1);
3355
+ }
3356
+ const contactIdsByNormalizedName = new Map(params.contacts
3357
+ .filter((contact) => !contact.isVariation)
3358
+ .map((contact) => {
3359
+ const fullName = normalizeLooseMatchText(normalizeLookupWhitespace(`${contact.firstName} ${contact.lastName}`));
3360
+ return [fullName, contact.contact_id];
3361
+ })
3362
+ .filter(([fullName]) => Boolean(fullName) && (normalizedNameCounts.get(fullName) ?? 0) === 1));
2050
3363
  const rowsByContactId = new Map();
2051
3364
  const body = params.parsedBody && typeof params.parsedBody === "object" && !Array.isArray(params.parsedBody)
2052
3365
  ? params.parsedBody
@@ -2056,13 +3369,34 @@ function normalizeWorkflowLinkedInUrlResult(params) {
2056
3369
  ...(Array.isArray(body?.profiles) ? body?.profiles : [])
2057
3370
  ];
2058
3371
  for (const contact of workflowRows) {
3372
+ const fullNameCandidate = normalizeLookupWhitespace(typeof contact.full_name === "string"
3373
+ ? contact.full_name
3374
+ : typeof contact.fullName === "string"
3375
+ ? contact.fullName
3376
+ : typeof contact.name === "string"
3377
+ ? contact.name
3378
+ : [contact.first_name, contact.last_name]
3379
+ .filter((value) => typeof value === "string" && value.trim().length > 0)
3380
+ .join(" "));
3381
+ const companyNameCandidate = normalizeLookupWhitespace(typeof contact.company_name === "string"
3382
+ ? contact.company_name
3383
+ : typeof contact.companyName === "string"
3384
+ ? contact.companyName
3385
+ : typeof contact.current_company === "string"
3386
+ ? contact.current_company
3387
+ : "");
3388
+ const normalizedIdentityKey = `${normalizeLooseMatchText(fullNameCandidate)}|${normalizeLooseMatchText(companyNameCandidate)}`;
2059
3389
  const explicitContactId = typeof contact.contact_id === "string"
2060
3390
  ? contact.contact_id
2061
3391
  : typeof contact.contact_id === "number"
2062
3392
  ? String(contact.contact_id)
2063
3393
  : "";
2064
3394
  const emailKey = typeof contact.email === "string" ? contact.email.toLowerCase() : "";
2065
- const contactId = explicitContactId || contactIdsBySyntheticEmail.get(emailKey) || "";
3395
+ const contactId = (inputContactIds.has(explicitContactId) ? explicitContactId : "") ||
3396
+ contactIdsBySyntheticEmail.get(emailKey) ||
3397
+ contactIdsByNormalizedIdentity.get(normalizedIdentityKey) ||
3398
+ contactIdsByNormalizedName.get(normalizeLooseMatchText(fullNameCandidate)) ||
3399
+ "";
2066
3400
  const linkedinUrl = normalizePublicLinkedInProfileUrl(typeof contact.linkedin_profile_url === "string"
2067
3401
  ? contact.linkedin_profile_url
2068
3402
  : typeof contact.linkedinProfileUrl === "string"
@@ -2149,7 +3483,8 @@ async function fetchSalesNavLookupCandidates(params) {
2149
3483
  }
2150
3484
  async function resolveLinkedInUrlsFromSalesNavRows(params) {
2151
3485
  const results = [];
2152
- for (const [index, row] of params.rows.entries()) {
3486
+ for (const row of params.rows) {
3487
+ const contactId = normalizeLinkedInLookupField(row.contactId) ?? `${results.length + 1}`;
2153
3488
  const candidates = await fetchSalesNavLookupCandidates({
2154
3489
  companyName: row.companyName,
2155
3490
  orgId: params.orgId
@@ -2173,51 +3508,269 @@ async function resolveLinkedInUrlsFromSalesNavRows(params) {
2173
3508
  (candidateName.includes(normalizedName) || normalizedName.includes(candidateName))) {
2174
3509
  score += 50;
2175
3510
  }
2176
- if (!normalizedName && candidateCompany) {
2177
- score += 5;
3511
+ if (!normalizedName && candidateCompany) {
3512
+ score += 5;
3513
+ }
3514
+ return { candidate, score };
3515
+ })
3516
+ .filter((entry) => entry.score >= (normalizedName ? 120 : 60))
3517
+ .sort((left, right) => {
3518
+ const leftUrl = left.candidate.linkedInProfileUrl ?? left.candidate.salesNavProfileUrl;
3519
+ const rightUrl = right.candidate.linkedInProfileUrl ?? right.candidate.salesNavProfileUrl;
3520
+ return right.score - left.score || Number(Boolean(rightUrl)) - Number(Boolean(leftUrl));
3521
+ });
3522
+ const best = ranked[0]?.candidate;
3523
+ const salesNavProfileUrl = best?.salesNavProfileUrl ?? null;
3524
+ const linkedinUrl = best?.linkedInProfileUrl ?? best?.salesNavProfileUrl ?? null;
3525
+ const linkedinCompanyUrl = (() => {
3526
+ const handle = normalizeLinkedInCompanyHandle(best?.regularCompanyUrl ?? "") ??
3527
+ normalizeLinkedInCompanyHandle(best?.companyUrl ?? "");
3528
+ if (handle) {
3529
+ return normalizeLinkedInCompanyPage(handle);
3530
+ }
3531
+ const numericCompanyUrl = typeof best?.companyUrl === "string" ? best.companyUrl.trim() : "";
3532
+ return numericCompanyUrl.length > 0 ? numericCompanyUrl : null;
3533
+ })();
3534
+ const salesNavCompanyUrl = typeof best?.companyUrl === "string" && /\/sales\/company\//i.test(best.companyUrl)
3535
+ ? best.companyUrl
3536
+ : null;
3537
+ const existingLinkedInCompanyUrl = row.linkedinCompanyUrl?.trim() || null;
3538
+ results.push({
3539
+ clientId: row.clientId,
3540
+ fullName: row.fullName,
3541
+ companyName: row.companyName,
3542
+ linkedinUrl,
3543
+ salesNavProfileUrl,
3544
+ linkedinCompanyUrl: linkedinCompanyUrl ?? existingLinkedInCompanyUrl,
3545
+ salesNavCompanyUrl,
3546
+ found: Boolean(linkedinUrl),
3547
+ companyFound: Boolean(linkedinCompanyUrl ?? existingLinkedInCompanyUrl),
3548
+ contactId,
3549
+ source: linkedinUrl ? "salesnav-supabase" : null,
3550
+ companySource: linkedinCompanyUrl ? "salesnav-supabase" : existingLinkedInCompanyUrl ? "input" : null,
3551
+ matchedFullName: best?.fullName ?? null,
3552
+ matchedCompanyName: best?.companyName ?? null,
3553
+ matchedTitle: best?.title ?? null,
3554
+ matchedOrgId: best?.orgId ?? null,
3555
+ matchedCompanyEmployeeCount: null
3556
+ });
3557
+ }
3558
+ return results;
3559
+ }
3560
+ function shouldUseSalesNavRowPrepass(params) {
3561
+ const env = params.env ?? process.env;
3562
+ const explicit = env.SALESPROMPTER_LINKEDIN_ROW_PREPASS?.trim().toLowerCase();
3563
+ if (explicit === "0" || explicit === "false" || explicit === "off") {
3564
+ return false;
3565
+ }
3566
+ if (explicit === "1" || explicit === "true" || explicit === "on") {
3567
+ return true;
3568
+ }
3569
+ const hasOrgId = Boolean(params.orgId?.trim());
3570
+ const hasSupabase = Boolean(env.NEXT_PUBLIC_SUPABASE_URL?.trim() && env.SUPABASE_SERVICE_ROLE_KEY?.trim());
3571
+ const maxRows = Number(env.SALESPROMPTER_LINKEDIN_ROW_PREPASS_MAX_ROWS ?? 200);
3572
+ if (!hasOrgId || !hasSupabase) {
3573
+ return false;
3574
+ }
3575
+ return params.rows.length <= (Number.isFinite(maxRows) && maxRows > 0 ? maxRows : 200);
3576
+ }
3577
+ function shouldUseDirectPeopleLookup(params) {
3578
+ const env = params.env ?? process.env;
3579
+ const explicit = env.SALESPROMPTER_LINKEDIN_DIRECT_PROFILE_LOOKUP?.trim().toLowerCase();
3580
+ if (explicit === "0" || explicit === "false" || explicit === "off") {
3581
+ return false;
3582
+ }
3583
+ if (explicit === "1" || explicit === "true" || explicit === "on") {
3584
+ return true;
3585
+ }
3586
+ const maxRows = Number(env.SALESPROMPTER_LINKEDIN_DIRECT_PROFILE_MAX_ROWS ?? 50);
3587
+ return params.rowCount <= (Number.isFinite(maxRows) && maxRows > 0 ? maxRows : 50);
3588
+ }
3589
+ function shouldUseWorkflowPeopleLookup(params) {
3590
+ const env = params.env ?? process.env;
3591
+ const explicit = env.SALESPROMPTER_LINKEDIN_WORKFLOW_PROFILE_LOOKUP?.trim().toLowerCase();
3592
+ if (explicit === "0" || explicit === "false" || explicit === "off") {
3593
+ return false;
3594
+ }
3595
+ if (explicit === "1" || explicit === "true" || explicit === "on") {
3596
+ return true;
3597
+ }
3598
+ const hasSerper = Boolean(getSerperApiKey(env));
3599
+ const maxRows = Number(env.SALESPROMPTER_LINKEDIN_WORKFLOW_PROFILE_MAX_ROWS ?? (hasSerper ? 75 : 250));
3600
+ return params.rowCount <= (Number.isFinite(maxRows) && maxRows > 0 ? maxRows : hasSerper ? 75 : 250);
3601
+ }
3602
+ function shouldUseBulkProfileResolutionStrategy(params) {
3603
+ const env = params.env ?? process.env;
3604
+ const explicit = env.SALESPROMPTER_LINKEDIN_BULK_MODE?.trim().toLowerCase();
3605
+ if (explicit === "0" || explicit === "false" || explicit === "off") {
3606
+ return false;
3607
+ }
3608
+ if (explicit === "1" || explicit === "true" || explicit === "on") {
3609
+ return true;
3610
+ }
3611
+ const minRows = Number(env.SALESPROMPTER_LINKEDIN_BULK_MODE_MIN_ROWS ?? 75);
3612
+ return params.rowCount >= (Number.isFinite(minRows) && minRows > 0 ? minRows : 75);
3613
+ }
3614
+ function resolveLinkedInBulkStrategyConfig(params) {
3615
+ const env = params.env ?? process.env;
3616
+ const bulkMode = shouldUseBulkProfileResolutionStrategy({
3617
+ rowCount: params.rowCount,
3618
+ env
3619
+ });
3620
+ const serperConcurrencyDefault = bulkMode ? 12 : 6;
3621
+ const serperConcurrency = Number(env.SALESPROMPTER_LINKEDIN_SERPER_CONCURRENCY ?? serperConcurrencyDefault);
3622
+ const serperMaxQueriesDefault = bulkMode ? 4 : 8;
3623
+ const serperMaxQueries = Number(env.SALESPROMPTER_LINKEDIN_SERPER_MAX_QUERIES ?? serperMaxQueriesDefault);
3624
+ const workflowStageBudgetDefault = bulkMode ? 8_000 : 15_000;
3625
+ const workflowStageBudgetMs = Number(env.SALESPROMPTER_LINKEDIN_WORKFLOW_STAGE_TIMEOUT_MS ?? workflowStageBudgetDefault);
3626
+ const serperStageBudgetDefault = bulkMode
3627
+ ? Math.max(15_000, Math.min(params.timeoutMs * 2, 45_000))
3628
+ : Math.max(10_000, Math.min(params.timeoutMs, 20_000));
3629
+ const serperStageBudgetMs = Number(env.SALESPROMPTER_LINKEDIN_SERPER_STAGE_TIMEOUT_MS ?? serperStageBudgetDefault);
3630
+ const bulkDirectProfileMaxRowsDefault = 0;
3631
+ const bulkDirectProfileMaxRows = Number(env.SALESPROMPTER_LINKEDIN_BULK_DIRECT_PROFILE_MAX_ROWS ?? bulkDirectProfileMaxRowsDefault);
3632
+ const bulkDirectProfileTimeoutDefault = bulkMode ? Math.min(params.timeoutMs, 6_000) : 0;
3633
+ const bulkDirectProfileTimeoutMs = Number(env.SALESPROMPTER_LINKEDIN_BULK_DIRECT_PROFILE_TIMEOUT_MS ?? bulkDirectProfileTimeoutDefault);
3634
+ return {
3635
+ bulkMode,
3636
+ serperConcurrency: Number.isFinite(serperConcurrency) && serperConcurrency > 0
3637
+ ? Math.trunc(serperConcurrency)
3638
+ : serperConcurrencyDefault,
3639
+ serperMaxQueries: Number.isFinite(serperMaxQueries) && serperMaxQueries > 0
3640
+ ? Math.trunc(serperMaxQueries)
3641
+ : serperMaxQueriesDefault,
3642
+ workflowStageBudgetMs: Number.isFinite(workflowStageBudgetMs) && workflowStageBudgetMs > 0
3643
+ ? Math.trunc(workflowStageBudgetMs)
3644
+ : workflowStageBudgetDefault,
3645
+ serperStageBudgetMs: Number.isFinite(serperStageBudgetMs) && serperStageBudgetMs > 0
3646
+ ? Math.trunc(serperStageBudgetMs)
3647
+ : serperStageBudgetDefault,
3648
+ bulkDirectProfileMaxRows: Number.isFinite(bulkDirectProfileMaxRows) && bulkDirectProfileMaxRows > 0
3649
+ ? Math.trunc(bulkDirectProfileMaxRows)
3650
+ : 0,
3651
+ bulkDirectProfileTimeoutMs: Number.isFinite(bulkDirectProfileTimeoutMs) && bulkDirectProfileTimeoutMs > 0
3652
+ ? Math.trunc(bulkDirectProfileTimeoutMs)
3653
+ : 0
3654
+ };
3655
+ }
3656
+ function shouldAttemptBulkDirectProfileLookup(params) {
3657
+ return (params.strategy.bulkMode &&
3658
+ params.strategy.bulkDirectProfileMaxRows > 0 &&
3659
+ params.strategy.bulkDirectProfileTimeoutMs > 0 &&
3660
+ params.unresolvedRowCount > 0);
3661
+ }
3662
+ function rankContactsForBulkDirectProfileLookup(params) {
3663
+ const scored = params.contacts
3664
+ .filter((contact) => !contact.isVariation)
3665
+ .map((contact) => {
3666
+ const row = params.rowsByContactId.get(contact.contact_id);
3667
+ const normalizedName = normalizeLookupWhitespace(`${contact.firstName} ${contact.lastName}`);
3668
+ const normalizedEmail = normalizeLookupWhitespace(contact.email);
3669
+ const titleKeywords = extractLookupTitleKeywords(contact.jobTitle);
3670
+ const roleKeywords = buildDeepDiveRoleSearchKeywords(contact.deepDiveRecommendedRole);
3671
+ let score = 0;
3672
+ if (row?.linkedinCompanyUrl || contact.linkedinCompanyUrl)
3673
+ score += 80;
3674
+ if (row?.salesNavCompanyUrl)
3675
+ score += 20;
3676
+ if (normalizedEmail && !isSyntheticLinkedInLookupEmail(normalizedEmail))
3677
+ score += 40;
3678
+ if (contact.jobTitle?.trim())
3679
+ score += 25;
3680
+ if (contact.deepDiveRecommendedRole?.trim())
3681
+ score += 15;
3682
+ score += Math.min(20, titleKeywords.length * 5);
3683
+ score += Math.min(15, roleKeywords.length * 5);
3684
+ if (/^contact\s+\d+$/i.test(normalizedName))
3685
+ score -= 100;
3686
+ if (/^(hr|support|facility|buchhaltung|rechnungen)$/i.test(normalizedName))
3687
+ score -= 25;
3688
+ return { contact, score };
3689
+ })
3690
+ .filter((entry) => entry.score > 0)
3691
+ .sort((left, right) => right.score - left.score);
3692
+ return scored.slice(0, params.limit).map((entry) => entry.contact);
3693
+ }
3694
+ async function resolveSerperLinkedInProfilesInParallel(params) {
3695
+ const results = new Map();
3696
+ const contacts = params.contacts;
3697
+ const concurrency = Math.max(1, Math.min(params.concurrency ?? 3, contacts.length || 1));
3698
+ const deadline = params.overallBudgetMs && Number.isFinite(params.overallBudgetMs) && params.overallBudgetMs > 0
3699
+ ? Date.now() + Math.trunc(params.overallBudgetMs)
3700
+ : Number.POSITIVE_INFINITY;
3701
+ let nextIndex = 0;
3702
+ const worker = async () => {
3703
+ while (true) {
3704
+ if (Date.now() >= deadline) {
3705
+ return;
3706
+ }
3707
+ const index = nextIndex++;
3708
+ if (index >= contacts.length) {
3709
+ return;
3710
+ }
3711
+ const contact = contacts[index];
3712
+ const remainingBudget = deadline - Date.now();
3713
+ if (remainingBudget <= 0) {
3714
+ return;
3715
+ }
3716
+ const linkedinUrl = await searchSerperLinkedInProfileUrl(contact, Math.min(params.timeoutMs, remainingBudget), {
3717
+ maxQueries: params.maxQueries
3718
+ });
3719
+ if (linkedinUrl) {
3720
+ results.set(contact.contact_id, linkedinUrl);
3721
+ }
3722
+ }
3723
+ };
3724
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
3725
+ return results;
3726
+ }
3727
+ async function resolveLinkedInCompanyUrlsForContacts(params) {
3728
+ const contacts = params.contacts.filter((contact) => !contact.isVariation && !contact.linkedinCompanyUrl);
3729
+ const uniqueCompanies = new Map();
3730
+ for (const contact of contacts) {
3731
+ const key = normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName);
3732
+ if (!key || uniqueCompanies.has(key)) {
3733
+ continue;
3734
+ }
3735
+ uniqueCompanies.set(key, contact.companyNameOriginal ?? contact.companyName);
3736
+ }
3737
+ const resultsByCompany = new Map();
3738
+ const entries = Array.from(uniqueCompanies.entries());
3739
+ const concurrency = Math.max(1, Math.min(params.concurrency ?? 4, entries.length || 1));
3740
+ const deadline = params.overallBudgetMs && Number.isFinite(params.overallBudgetMs) && params.overallBudgetMs > 0
3741
+ ? Date.now() + Math.trunc(params.overallBudgetMs)
3742
+ : Number.POSITIVE_INFINITY;
3743
+ let nextIndex = 0;
3744
+ const worker = async () => {
3745
+ while (true) {
3746
+ if (Date.now() >= deadline) {
3747
+ return;
3748
+ }
3749
+ const index = nextIndex++;
3750
+ if (index >= entries.length) {
3751
+ return;
3752
+ }
3753
+ const [key, companyName] = entries[index];
3754
+ const remainingBudget = deadline - Date.now();
3755
+ if (remainingBudget <= 0) {
3756
+ return;
2178
3757
  }
2179
- return { candidate, score };
2180
- })
2181
- .filter((entry) => entry.score >= (normalizedName ? 120 : 60))
2182
- .sort((left, right) => {
2183
- const leftUrl = left.candidate.linkedInProfileUrl ?? left.candidate.salesNavProfileUrl;
2184
- const rightUrl = right.candidate.linkedInProfileUrl ?? right.candidate.salesNavProfileUrl;
2185
- return right.score - left.score || Number(Boolean(rightUrl)) - Number(Boolean(leftUrl));
2186
- });
2187
- const best = ranked[0]?.candidate;
2188
- const salesNavProfileUrl = best?.salesNavProfileUrl ?? null;
2189
- const linkedinUrl = best?.linkedInProfileUrl ?? best?.salesNavProfileUrl ?? null;
2190
- const linkedinCompanyUrl = (() => {
2191
- const handle = normalizeLinkedInCompanyHandle(best?.regularCompanyUrl ?? "") ??
2192
- normalizeLinkedInCompanyHandle(best?.companyUrl ?? "");
2193
- if (handle) {
2194
- return normalizeLinkedInCompanyPage(handle);
3758
+ const perCompanyTimeout = Math.min(params.timeoutMs, remainingBudget);
3759
+ const linkedinUrl = (await searchSerperLinkedInCompanyUrl(companyName, perCompanyTimeout)) ??
3760
+ (await searchPublicLinkedInCompanyUrl(companyName, perCompanyTimeout));
3761
+ if (linkedinUrl) {
3762
+ resultsByCompany.set(key, linkedinUrl);
2195
3763
  }
2196
- const numericCompanyUrl = typeof best?.companyUrl === "string" ? best.companyUrl.trim() : "";
2197
- return numericCompanyUrl.length > 0 ? numericCompanyUrl : null;
2198
- })();
2199
- const salesNavCompanyUrl = typeof best?.companyUrl === "string" && /\/sales\/company\//i.test(best.companyUrl)
2200
- ? best.companyUrl
2201
- : null;
2202
- results.push({
2203
- clientId: row.clientId,
2204
- fullName: row.fullName,
2205
- companyName: row.companyName,
2206
- linkedinUrl,
2207
- salesNavProfileUrl,
2208
- linkedinCompanyUrl,
2209
- salesNavCompanyUrl,
2210
- found: Boolean(linkedinUrl),
2211
- companyFound: Boolean(linkedinCompanyUrl),
2212
- contactId: String(index + 1),
2213
- source: linkedinUrl ? "salesnav-supabase" : null,
2214
- companySource: linkedinCompanyUrl ? "salesnav-supabase" : null,
2215
- matchedFullName: best?.fullName ?? null,
2216
- matchedCompanyName: best?.companyName ?? null,
2217
- matchedTitle: best?.title ?? null,
2218
- matchedOrgId: best?.orgId ?? null,
2219
- matchedCompanyEmployeeCount: null
2220
- });
3764
+ }
3765
+ };
3766
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
3767
+ const results = new Map();
3768
+ for (const contact of params.contacts) {
3769
+ const key = normalizeLookupCompanyForCleaning(contact.companyNameOriginal ?? contact.companyName);
3770
+ const linkedinUrl = resultsByCompany.get(key);
3771
+ if (linkedinUrl) {
3772
+ results.set(contact.contact_id, linkedinUrl);
3773
+ }
2221
3774
  }
2222
3775
  return results;
2223
3776
  }
@@ -3208,6 +4761,72 @@ async function fetchWorkspaceLeadSearch(session, requestBody) {
3208
4761
  }
3209
4762
  return WorkspaceLeadSearchResponseSchema.parse(payload).leads;
3210
4763
  }
4764
+ async function buildWorkspaceLeadAccount(icp, target, leads) {
4765
+ const firstLead = leads[0];
4766
+ if (firstLead) {
4767
+ 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)));
4768
+ return AccountProfileSchema.parse({
4769
+ companyName: target.companyName?.trim() || firstLead.companyName,
4770
+ domain: target.companyDomain?.trim().toLowerCase() || firstLead.domain,
4771
+ industry: firstLead.industry,
4772
+ region: firstLead.region,
4773
+ employeeCount: firstLead.employeeCount,
4774
+ keywords,
4775
+ sources: ["workspace-qualified-leads"]
4776
+ });
4777
+ }
4778
+ return await companyProvider.resolveCompany({
4779
+ companyDomain: target.companyDomain,
4780
+ companyName: target.companyName
4781
+ }, icp);
4782
+ }
4783
+ async function generateLeadsForCommand(options) {
4784
+ const source = z.enum(["auto", "workspace", "fallback"]).parse(options.source ?? "auto");
4785
+ if (source === "fallback") {
4786
+ return await leadProvider.generateLeads(options.icp, options.count, options.target);
4787
+ }
4788
+ if (shouldBypassAuth()) {
4789
+ if (source === "workspace") {
4790
+ throw new Error("workspace lead generation requires authentication. Disable SALESPROMPTER_SKIP_AUTH and log in first.");
4791
+ }
4792
+ return await leadProvider.generateLeads(options.icp, options.count, options.target);
4793
+ }
4794
+ try {
4795
+ const session = await requireAuthSession();
4796
+ const requestBody = options.target.companyDomain || options.target.linkedinCompanyPage
4797
+ ? {
4798
+ mode: "target-company",
4799
+ domain: options.target.companyDomain,
4800
+ linkedinCompanyPage: options.target.linkedinCompanyPage,
4801
+ limit: options.count
4802
+ }
4803
+ : {
4804
+ mode: "reference-company",
4805
+ icp: options.icp,
4806
+ limit: options.count
4807
+ };
4808
+ const leads = await fetchWorkspaceLeadSearch(session, requestBody);
4809
+ const account = await buildWorkspaceLeadAccount(options.icp, options.target, leads);
4810
+ return {
4811
+ provider: "salesprompter-app-workspace-search",
4812
+ mode: "real",
4813
+ account,
4814
+ leads,
4815
+ warnings: []
4816
+ };
4817
+ }
4818
+ catch (error) {
4819
+ if (source === "workspace") {
4820
+ throw error;
4821
+ }
4822
+ const fallback = await leadProvider.generateLeads(options.icp, options.count, options.target);
4823
+ const message = error instanceof Error ? error.message : String(error);
4824
+ return {
4825
+ ...fallback,
4826
+ warnings: [`Workspace lead search unavailable: ${message}`, ...fallback.warnings]
4827
+ };
4828
+ }
4829
+ }
3211
4830
  function buildLinkedInProductsOutputPath(categorySlug) {
3212
4831
  return `./data/linkedin-products-${categorySlug}.json`;
3213
4832
  }
@@ -3994,6 +5613,17 @@ async function fetchLinkedInCompaniesBackfillStatus(session, payload) {
3994
5613
  }), LinkedInCompanyBackfillStatusResponseSchema);
3995
5614
  return value;
3996
5615
  }
5616
+ async function syncPhantombusterContainersViaApp(session, payload) {
5617
+ const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/phantombuster/containers/sync`, {
5618
+ method: "POST",
5619
+ headers: {
5620
+ "Content-Type": "application/json",
5621
+ Authorization: `Bearer ${currentSession.accessToken}`
5622
+ },
5623
+ body: JSON.stringify(payload)
5624
+ }), PhantombusterContainersSyncResponseSchema);
5625
+ return value;
5626
+ }
3997
5627
  function serializeSalesNavigatorFiltersForApi(filters) {
3998
5628
  return filters.map((filter) => ({
3999
5629
  type: filter.type,
@@ -4020,6 +5650,12 @@ function buildSalesNavigatorSliceRawPayload(slice, extra = {}) {
4020
5650
  resultRetryCount: slice.resultRetryCount ?? null
4021
5651
  };
4022
5652
  }
5653
+ function parseOptionalSalesNavigatorClientId(value) {
5654
+ if (value == null || String(value).trim().length === 0) {
5655
+ return null;
5656
+ }
5657
+ return z.coerce.number().int().positive().parse(value);
5658
+ }
4023
5659
  function buildSalesNavigatorCrawlReportRawPayload(slice, traceId, extra = {}) {
4024
5660
  return buildSalesNavigatorSliceRawPayload({
4025
5661
  sourceQueryUrl: slice.sourceQueryUrl,
@@ -4480,11 +6116,12 @@ function isSalesNavigatorSessionError(error) {
4480
6116
  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);
4481
6117
  }
4482
6118
  function isSalesNavigatorResultArtifactError(error) {
4483
- if (error instanceof SalesNavigatorExportRequestError && error.errorCode === "phantombuster_result_invalid") {
6119
+ if (error instanceof SalesNavigatorExportRequestError &&
6120
+ ["phantombuster_result_invalid", "partial_result_artifact"].includes(error.errorCode ?? "")) {
4484
6121
  return true;
4485
6122
  }
4486
6123
  const message = error instanceof Error ? error.message : String(error);
4487
- return /page has crashed|no valid sales navigator people rows/i.test(message);
6124
+ 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);
4488
6125
  }
4489
6126
  function isSalesNavigatorTransientExportError(error) {
4490
6127
  if (isSalesNavigatorSessionError(error) || isSalesNavigatorResultArtifactError(error)) {
@@ -4575,6 +6212,7 @@ async function runSalesNavigatorCrawlAttempt(session, attempt, options, context)
4575
6212
  crawlSliceId: context?.crawlSliceId,
4576
6213
  rawPayload: buildSalesNavigatorSliceRawPayload(attempt, {
4577
6214
  traceId: context?.traceId ?? null,
6215
+ clientId: context?.clientId ?? null,
4578
6216
  phase: shouldProbe ? "probe" : "full_export",
4579
6217
  requestedProfiles: probeProfiles,
4580
6218
  crawlJobId: context?.crawlJobId ?? null,
@@ -4611,6 +6249,7 @@ async function runSalesNavigatorCrawlAttempt(session, attempt, options, context)
4611
6249
  crawlSliceId: context?.crawlSliceId,
4612
6250
  rawPayload: buildSalesNavigatorSliceRawPayload(attempt, {
4613
6251
  traceId: context?.traceId ?? null,
6252
+ clientId: context?.clientId ?? null,
4614
6253
  phase: "full_export_after_probe",
4615
6254
  requestedProfiles: attempt.numberOfProfiles,
4616
6255
  crawlJobId: context?.crawlJobId ?? null,
@@ -4709,6 +6348,8 @@ const SALES_NAVIGATOR_SPLIT_TRIGGER_RESULTS = 1500;
4709
6348
  const SALES_NAVIGATOR_FILTER_IMPACT_MIN_OBSERVATIONS = 3;
4710
6349
  let salesNavigatorFilterImpactModel = null;
4711
6350
  let salesNavigatorFilterImpactLoaded = false;
6351
+ let linkedInProfileHitCache = null;
6352
+ let linkedInProfileHitCacheLoaded = false;
4712
6353
  function getSalesprompterConfigDir() {
4713
6354
  const override = process.env.SALESPROMPTER_CONFIG_DIR?.trim();
4714
6355
  if (override !== undefined && override.length > 0) {
@@ -4719,6 +6360,76 @@ function getSalesprompterConfigDir() {
4719
6360
  function getSalesNavigatorFilterImpactPath() {
4720
6361
  return path.join(getSalesprompterConfigDir(), "salesnav-filter-impact.json");
4721
6362
  }
6363
+ function getLinkedInProfileHitCachePath() {
6364
+ return path.join(getSalesprompterConfigDir(), "linkedin-profile-hits.json");
6365
+ }
6366
+ function buildLinkedInProfileHitCacheKeys(params) {
6367
+ const keys = new Set();
6368
+ const normalizedName = normalizeLooseMatchText(params.fullName);
6369
+ const normalizedCompany = normalizeLooseMatchText(params.companyName);
6370
+ const normalizedEmail = normalizeLookupWhitespace(params.email);
6371
+ const trustedEmail = normalizedEmail && !isSyntheticLinkedInLookupEmail(normalizedEmail) ? normalizedEmail.toLowerCase() : "";
6372
+ const contactId = normalizeLinkedInLookupField(params.contactId);
6373
+ if (contactId && !/^[1-9]\d?$/.test(contactId)) {
6374
+ keys.add(`contact:${contactId}`);
6375
+ }
6376
+ if (normalizedName && normalizedCompany && trustedEmail) {
6377
+ keys.add(`identity:${normalizedName}|${normalizedCompany}|${trustedEmail}`);
6378
+ }
6379
+ if (normalizedName && normalizedCompany) {
6380
+ keys.add(`identity:${normalizedName}|${normalizedCompany}`);
6381
+ }
6382
+ return Array.from(keys);
6383
+ }
6384
+ async function loadLinkedInProfileHitCache() {
6385
+ if (linkedInProfileHitCacheLoaded) {
6386
+ return linkedInProfileHitCache;
6387
+ }
6388
+ linkedInProfileHitCacheLoaded = true;
6389
+ try {
6390
+ const content = await readFile(getLinkedInProfileHitCachePath(), "utf8");
6391
+ const parsed = JSON.parse(content);
6392
+ if (parsed?.version === 1 && parsed.entries && typeof parsed.entries === "object") {
6393
+ linkedInProfileHitCache = parsed;
6394
+ }
6395
+ }
6396
+ catch {
6397
+ linkedInProfileHitCache = null;
6398
+ }
6399
+ return linkedInProfileHitCache;
6400
+ }
6401
+ async function persistLinkedInProfileHitCache() {
6402
+ if (!linkedInProfileHitCache) {
6403
+ return;
6404
+ }
6405
+ const filePath = getLinkedInProfileHitCachePath();
6406
+ await mkdir(path.dirname(filePath), { recursive: true });
6407
+ await writeFile(filePath, `${JSON.stringify(linkedInProfileHitCache, null, 2)}\n`, "utf8");
6408
+ }
6409
+ function upsertLinkedInProfileHitCacheEntry(params) {
6410
+ if (!params.linkedinUrl && !params.salesNavProfileUrl && !params.linkedinCompanyUrl && !params.salesNavCompanyUrl) {
6411
+ return;
6412
+ }
6413
+ if (!linkedInProfileHitCache) {
6414
+ linkedInProfileHitCache = {
6415
+ version: 1,
6416
+ updatedAt: new Date().toISOString(),
6417
+ entries: {}
6418
+ };
6419
+ }
6420
+ const updatedAt = new Date().toISOString();
6421
+ linkedInProfileHitCache.updatedAt = updatedAt;
6422
+ const entry = {
6423
+ linkedinUrl: params.linkedinUrl,
6424
+ salesNavProfileUrl: params.salesNavProfileUrl,
6425
+ linkedinCompanyUrl: params.linkedinCompanyUrl,
6426
+ salesNavCompanyUrl: params.salesNavCompanyUrl,
6427
+ updatedAt
6428
+ };
6429
+ for (const key of buildLinkedInProfileHitCacheKeys(params)) {
6430
+ linkedInProfileHitCache.entries[key] = entry;
6431
+ }
6432
+ }
4722
6433
  async function loadSalesNavigatorFilterImpactModel() {
4723
6434
  if (salesNavigatorFilterImpactLoaded) {
4724
6435
  return salesNavigatorFilterImpactModel;
@@ -4991,6 +6702,7 @@ async function processSalesNavigatorClaimedCrawlSlice(session, jobId, slice, opt
4991
6702
  }, {
4992
6703
  crawlJobId: jobId,
4993
6704
  crawlSliceId: slice.id,
6705
+ clientId: options.clientId ?? null,
4994
6706
  traceId: options.traceId
4995
6707
  });
4996
6708
  const reported = await reportSalesNavigatorCrawlSlice(currentSession, jobId, {
@@ -5267,6 +6979,7 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
5267
6979
  agentBusyWaitSeconds: options.agentBusyWaitSeconds,
5268
6980
  agentBusyMaxWaits: options.agentBusyMaxWaits,
5269
6981
  claimedSlices: claimedSliceNumber,
6982
+ clientId: options.clientId ?? null,
5270
6983
  traceId: options.traceId,
5271
6984
  logger: options.logger
5272
6985
  }).then((value) => ({ slot, value })));
@@ -6230,6 +7943,7 @@ program
6230
7943
  const companyCleaningMode = resolveCompanyCleaningMode(String(options.companyCleaning ?? process.env.SALESPROMPTER_COMPANY_CLEANING_MODE ?? "basic"));
6231
7944
  const cleanedCompanyMap = await buildCompanyNameCleaningMap(rows, companyCleaningMode);
6232
7945
  const contacts = toLinkedInUrlLookupContacts(rows, cleanedCompanyMap);
7946
+ await loadLinkedInProfileHitCache();
6233
7947
  if (options.dryRun) {
6234
7948
  const payload = {
6235
7949
  status: "ok",
@@ -6245,79 +7959,70 @@ program
6245
7959
  printOutput(payload);
6246
7960
  return;
6247
7961
  }
6248
- const enrichedRows = await resolveLinkedInUrlsFromSalesNavRows({
6249
- rows,
6250
- orgId: String(options.orgId ?? "").trim() || undefined
7962
+ const orgId = String(options.orgId ?? "").trim() || undefined;
7963
+ const strategy = resolveLinkedInBulkStrategyConfig({
7964
+ rowCount: rows.length,
7965
+ timeoutMs
6251
7966
  });
6252
- let directAttempted = false;
6253
- let workflowAttempted = false;
6254
- const missingRows = enrichedRows.filter((row) => !row.found);
6255
- if (missingRows.length > 0) {
6256
- const directContacts = contacts.filter((contact) => missingRows.some((row) => row.contactId === contact.contact_id));
6257
- let linkedInUrlByContactId = new Map();
6258
- try {
6259
- directAttempted = true;
6260
- const result = await invokeLinkedInUrlEnrichmentDirect({
6261
- contacts: directContacts,
6262
- timeoutMs
6263
- });
6264
- linkedInUrlByContactId = new Map(result.contacts.map((contact) => [
6265
- contact.contact_id,
6266
- {
6267
- linkedinUrl: contact.linkedin_url ?? null,
6268
- salesNavProfileUrl: contact.sales_nav_profile_url ?? null,
6269
- linkedinCompanyUrl: null,
6270
- salesNavCompanyUrl: null
6271
- }
6272
- ]));
6273
- for (const row of enrichedRows) {
6274
- if (row.found)
6275
- continue;
6276
- const profile = linkedInUrlByContactId.get(row.contactId);
6277
- if (profile?.linkedinUrl) {
6278
- row.linkedinUrl = profile.linkedinUrl;
6279
- row.salesNavProfileUrl = profile.salesNavProfileUrl ?? row.salesNavProfileUrl ?? null;
6280
- row.found = true;
6281
- row.source = "linkedin-direct";
6282
- }
6283
- }
7967
+ const useSalesNavRowPrepass = !strategy.bulkMode &&
7968
+ shouldUseSalesNavRowPrepass({
7969
+ rows,
7970
+ orgId
7971
+ });
7972
+ const enrichedRows = useSalesNavRowPrepass
7973
+ ? await resolveLinkedInUrlsFromSalesNavRows({
7974
+ rows,
7975
+ orgId
7976
+ })
7977
+ : rows.map((row, index) => ({
7978
+ clientId: row.clientId,
7979
+ fullName: row.fullName,
7980
+ companyName: row.companyName,
7981
+ linkedinUrl: null,
7982
+ salesNavProfileUrl: null,
7983
+ linkedinCompanyUrl: row.linkedinCompanyUrl?.trim() || null,
7984
+ salesNavCompanyUrl: null,
7985
+ found: false,
7986
+ companyFound: Boolean(row.linkedinCompanyUrl?.trim()),
7987
+ contactId: normalizeLinkedInLookupField(row.contactId) ?? `${index + 1}`,
7988
+ source: null,
7989
+ companySource: row.linkedinCompanyUrl?.trim() ? "input" : null,
7990
+ matchedFullName: null,
7991
+ matchedCompanyName: null,
7992
+ matchedTitle: null,
7993
+ matchedOrgId: null,
7994
+ matchedCompanyEmployeeCount: null
7995
+ }));
7996
+ const contactById = new Map(contacts.filter((contact) => !contact.isVariation).map((contact) => [contact.contact_id, contact]));
7997
+ for (const row of enrichedRows) {
7998
+ if (row.found) {
7999
+ continue;
6284
8000
  }
6285
- catch (error) {
6286
- const message = error instanceof Error ? error.message : String(error);
6287
- if (!/Missing LinkedIn direct lookup session/i.test(message)) {
6288
- throw error;
6289
- }
6290
- workflowAttempted = true;
6291
- const workflow = await invokeLinkedInUrlEnrichmentWorkflow({
6292
- contacts: directContacts,
6293
- externalUserId: String(options.orgId ?? "").trim() || sessionOrgId || "cli_direct_lookup",
6294
- timeoutMs
6295
- });
6296
- if (!workflow.response.ok) {
6297
- throw new Error(`LinkedIn enrichment workflow returned ${workflow.response.status}: ${workflow.bodyText.slice(0, 300)}`);
6298
- }
6299
- linkedInUrlByContactId = normalizeWorkflowLinkedInUrlResult({
6300
- parsedBody: workflow.parsedBody,
6301
- contacts: directContacts
6302
- });
6303
- for (const row of enrichedRows) {
6304
- if (row.found)
6305
- continue;
6306
- const profile = linkedInUrlByContactId.get(row.contactId);
6307
- if (profile?.linkedinUrl) {
6308
- row.linkedinUrl = profile.linkedinUrl;
6309
- row.salesNavProfileUrl = profile.salesNavProfileUrl ?? row.salesNavProfileUrl ?? null;
6310
- row.linkedinCompanyUrl = profile.linkedinCompanyUrl ?? row.linkedinCompanyUrl ?? null;
6311
- row.salesNavCompanyUrl = profile.salesNavCompanyUrl ?? row.salesNavCompanyUrl ?? null;
6312
- row.found = true;
6313
- row.companyFound = Boolean(row.linkedinCompanyUrl || row.salesNavCompanyUrl);
6314
- row.source = "workflow";
6315
- row.companySource =
6316
- row.linkedinCompanyUrl || row.salesNavCompanyUrl ? "workflow" : row.companySource ?? null;
6317
- }
6318
- }
8001
+ const contact = contactById.get(row.contactId);
8002
+ const cacheKeys = buildLinkedInProfileHitCacheKeys({
8003
+ fullName: row.fullName,
8004
+ companyName: row.companyName,
8005
+ email: contact?.email,
8006
+ contactId: row.contactId
8007
+ });
8008
+ const cachedEntry = cacheKeys
8009
+ .map((key) => linkedInProfileHitCache?.entries[key] ?? null)
8010
+ .find(Boolean);
8011
+ if (!cachedEntry) {
8012
+ continue;
6319
8013
  }
8014
+ row.linkedinUrl = cachedEntry.linkedinUrl ?? row.linkedinUrl ?? null;
8015
+ row.salesNavProfileUrl = cachedEntry.salesNavProfileUrl ?? row.salesNavProfileUrl ?? null;
8016
+ row.linkedinCompanyUrl = cachedEntry.linkedinCompanyUrl ?? row.linkedinCompanyUrl ?? null;
8017
+ row.salesNavCompanyUrl = cachedEntry.salesNavCompanyUrl ?? row.salesNavCompanyUrl ?? null;
8018
+ row.found = Boolean(row.linkedinUrl || row.salesNavProfileUrl);
8019
+ row.companyFound = Boolean(row.linkedinCompanyUrl || row.salesNavCompanyUrl);
8020
+ row.source = row.found ? "cache" : row.source;
8021
+ row.companySource =
8022
+ row.companyFound && !row.companySource ? "cache" : row.companySource;
6320
8023
  }
8024
+ let directAttempted = false;
8025
+ let workflowAttempted = false;
6321
8026
  const parsedClientIds = Array.from(new Set(rows
6322
8027
  .map((row) => Number(row.clientId))
6323
8028
  .filter((value) => Number.isFinite(value) && value > 0)));
@@ -6364,37 +8069,265 @@ program
6364
8069
  writeProgress(`Skipping app-backed company enrichment: ${error instanceof Error ? error.message : String(error)}`);
6365
8070
  }
6366
8071
  }
6367
- try {
6368
- const companyResult = await invokeLinkedInCompanyEnrichmentDirect({
6369
- contacts,
6370
- timeoutMs
8072
+ const contactsMissingCompanyUrl = contacts.filter((contact) => !contact.isVariation &&
8073
+ enrichedRows.some((row) => row.contactId === contact.contact_id && !row.linkedinCompanyUrl));
8074
+ if (contactsMissingCompanyUrl.length > 0) {
8075
+ const companyUrlByContactId = await resolveLinkedInCompanyUrlsForContacts({
8076
+ contacts: contactsMissingCompanyUrl,
8077
+ timeoutMs: Math.min(timeoutMs, 15_000),
8078
+ concurrency: strategy.bulkMode ? 6 : 3,
8079
+ overallBudgetMs: strategy.bulkMode ? 20_000 : 10_000
6371
8080
  });
6372
- const companyByContactId = new Map(companyResult.contacts.map((contact) => [
6373
- contact.contact_id,
6374
- {
6375
- linkedinCompanyUrl: contact.linkedin_company_url ?? null,
6376
- salesNavCompanyUrl: contact.sales_nav_company_url ?? null,
6377
- matchedCompanyName: contact.matched_company_name ?? null,
6378
- matchedCompanyEmployeeCount: contact.matched_company_employee_count ?? null
6379
- }
6380
- ]));
6381
8081
  for (const row of enrichedRows) {
6382
- const company = companyByContactId.get(row.contactId);
6383
- if (!company || row.linkedinCompanyUrl) {
8082
+ if (row.linkedinCompanyUrl) {
6384
8083
  continue;
6385
8084
  }
6386
- row.linkedinCompanyUrl = company.linkedinCompanyUrl;
6387
- row.salesNavCompanyUrl = company.salesNavCompanyUrl ?? row.salesNavCompanyUrl ?? null;
6388
- row.companyFound = Boolean(company.linkedinCompanyUrl || company.salesNavCompanyUrl);
6389
- row.companySource =
6390
- company.linkedinCompanyUrl || company.salesNavCompanyUrl ? "linkedin-direct" : row.companySource ?? null;
6391
- row.matchedCompanyName = company.matchedCompanyName ?? row.matchedCompanyName ?? null;
6392
- row.matchedCompanyEmployeeCount =
6393
- company.matchedCompanyEmployeeCount ?? row.matchedCompanyEmployeeCount ?? null;
8085
+ const linkedinCompanyUrl = companyUrlByContactId.get(row.contactId);
8086
+ if (!linkedinCompanyUrl) {
8087
+ continue;
8088
+ }
8089
+ row.linkedinCompanyUrl = linkedinCompanyUrl;
8090
+ row.companyFound = true;
8091
+ row.companySource = "web-search";
6394
8092
  }
6395
8093
  }
6396
- catch (error) {
6397
- writeProgress(`Skipping separate company enrichment: ${error instanceof Error ? error.message : String(error)}`);
8094
+ const missingRows = enrichedRows.filter((row) => !row.found);
8095
+ const useDirectPeopleLookup = !strategy.bulkMode &&
8096
+ shouldUseDirectPeopleLookup({
8097
+ rowCount: missingRows.length
8098
+ });
8099
+ const useWorkflowPeopleLookup = !strategy.bulkMode &&
8100
+ shouldUseWorkflowPeopleLookup({
8101
+ rowCount: missingRows.length
8102
+ });
8103
+ if (missingRows.length > 0) {
8104
+ const rowByContactId = new Map(enrichedRows.map((row) => [row.contactId, row]));
8105
+ const directContacts = contacts
8106
+ .filter((contact) => missingRows.some((row) => row.contactId === contact.contact_id))
8107
+ .map((contact) => {
8108
+ const row = rowByContactId.get(contact.contact_id);
8109
+ if (!row) {
8110
+ return contact;
8111
+ }
8112
+ return {
8113
+ ...contact,
8114
+ linkedinCompanyUrl: row.linkedinCompanyUrl ?? contact.linkedinCompanyUrl,
8115
+ companyNameOriginal: row.matchedCompanyName ?? contact.companyNameOriginal,
8116
+ companyName: row.matchedCompanyName && normalizeLookupCompanyForSearch(row.matchedCompanyName)
8117
+ ? normalizeLookupCompanyForSearch(row.matchedCompanyName)
8118
+ : contact.companyName
8119
+ };
8120
+ });
8121
+ let linkedInUrlByContactId = new Map();
8122
+ if (useDirectPeopleLookup) {
8123
+ try {
8124
+ directAttempted = true;
8125
+ const result = await invokeLinkedInUrlEnrichmentDirect({
8126
+ contacts: directContacts,
8127
+ timeoutMs
8128
+ });
8129
+ const directCompanyContextByKey = new Map((result.companyContexts ?? []).map((context) => [context.normalizedCompanyKey, context]));
8130
+ linkedInUrlByContactId = new Map(result.contacts.map((contact) => [
8131
+ contact.contact_id,
8132
+ {
8133
+ linkedinUrl: contact.linkedin_url ?? null,
8134
+ salesNavProfileUrl: contact.sales_nav_profile_url ?? null,
8135
+ linkedinCompanyUrl: null,
8136
+ salesNavCompanyUrl: null,
8137
+ matchedFullName: contact.matched_full_name ?? null,
8138
+ matchedCompanyName: contact.matched_company_name ?? null,
8139
+ matchedTitle: contact.matched_title ?? null
8140
+ }
8141
+ ]));
8142
+ for (const row of enrichedRows) {
8143
+ if (row.found)
8144
+ continue;
8145
+ const profile = linkedInUrlByContactId.get(row.contactId);
8146
+ if (profile?.linkedinUrl) {
8147
+ row.linkedinUrl = profile.linkedinUrl;
8148
+ row.salesNavProfileUrl = profile.salesNavProfileUrl ?? row.salesNavProfileUrl ?? null;
8149
+ row.found = true;
8150
+ row.source = "linkedin-direct";
8151
+ row.matchedFullName = profile.matchedFullName ?? row.matchedFullName ?? null;
8152
+ row.matchedCompanyName = profile.matchedCompanyName ?? row.matchedCompanyName ?? null;
8153
+ row.matchedTitle = profile.matchedTitle ?? row.matchedTitle ?? null;
8154
+ }
8155
+ const directContact = directContacts.find((candidate) => candidate.contact_id === row.contactId && !candidate.isVariation);
8156
+ const companyContext = directContact
8157
+ ? directCompanyContextByKey.get(buildDirectCompanyContextKey(directContact))
8158
+ : null;
8159
+ if (companyContext && !row.linkedinCompanyUrl) {
8160
+ row.linkedinCompanyUrl = companyContext.linkedinCompanyUrl ?? row.linkedinCompanyUrl ?? null;
8161
+ row.salesNavCompanyUrl = companyContext.salesNavCompanyUrl ?? row.salesNavCompanyUrl ?? null;
8162
+ row.companyFound = Boolean(row.linkedinCompanyUrl || row.salesNavCompanyUrl);
8163
+ row.companySource =
8164
+ row.linkedinCompanyUrl || row.salesNavCompanyUrl ? "linkedin-direct" : row.companySource ?? null;
8165
+ row.matchedCompanyName = companyContext.matchedCompanyName ?? row.matchedCompanyName ?? null;
8166
+ row.matchedCompanyEmployeeCount =
8167
+ companyContext.matchedCompanyEmployeeCount ?? row.matchedCompanyEmployeeCount ?? null;
8168
+ }
8169
+ }
8170
+ const contactsStillMissingCompany = contacts.filter((contact) => !contact.isVariation &&
8171
+ enrichedRows.some((row) => row.contactId === contact.contact_id && !row.linkedinCompanyUrl && !row.salesNavCompanyUrl));
8172
+ if (contactsStillMissingCompany.length > 0) {
8173
+ const companyResult = await invokeLinkedInCompanyEnrichmentDirect({
8174
+ contacts: contactsStillMissingCompany,
8175
+ timeoutMs,
8176
+ precomputedContexts: result.companyContexts
8177
+ });
8178
+ const companyByContactId = new Map(companyResult.contacts.map((contact) => [
8179
+ contact.contact_id,
8180
+ {
8181
+ linkedinCompanyUrl: contact.linkedin_company_url ?? null,
8182
+ salesNavCompanyUrl: contact.sales_nav_company_url ?? null,
8183
+ matchedCompanyName: contact.matched_company_name ?? null,
8184
+ matchedCompanyEmployeeCount: contact.matched_company_employee_count ?? null
8185
+ }
8186
+ ]));
8187
+ for (const row of enrichedRows) {
8188
+ const company = companyByContactId.get(row.contactId);
8189
+ if (!company || row.linkedinCompanyUrl) {
8190
+ continue;
8191
+ }
8192
+ row.linkedinCompanyUrl = company.linkedinCompanyUrl;
8193
+ row.salesNavCompanyUrl = company.salesNavCompanyUrl ?? row.salesNavCompanyUrl ?? null;
8194
+ row.companyFound = Boolean(company.linkedinCompanyUrl || company.salesNavCompanyUrl);
8195
+ row.companySource =
8196
+ company.linkedinCompanyUrl || company.salesNavCompanyUrl ? "linkedin-direct" : row.companySource ?? null;
8197
+ row.matchedCompanyName = company.matchedCompanyName ?? row.matchedCompanyName ?? null;
8198
+ row.matchedCompanyEmployeeCount =
8199
+ company.matchedCompanyEmployeeCount ?? row.matchedCompanyEmployeeCount ?? null;
8200
+ }
8201
+ }
8202
+ }
8203
+ catch (error) {
8204
+ const message = error instanceof Error ? error.message : String(error);
8205
+ if (!/Missing LinkedIn direct lookup session/i.test(message)) {
8206
+ throw error;
8207
+ }
8208
+ }
8209
+ }
8210
+ const stillMissingAfterDirect = enrichedRows.filter((row) => !row.found);
8211
+ const contactsStillMissing = directContacts.filter((contact) => stillMissingAfterDirect.some((row) => row.contactId === contact.contact_id));
8212
+ if (contactsStillMissing.length > 0 && useWorkflowPeopleLookup) {
8213
+ workflowAttempted = true;
8214
+ try {
8215
+ const workflow = await invokeLinkedInUrlEnrichmentWorkflow({
8216
+ contacts: contactsStillMissing,
8217
+ externalUserId: orgId || sessionOrgId || "cli_direct_lookup",
8218
+ timeoutMs: Math.min(timeoutMs, strategy.workflowStageBudgetMs)
8219
+ });
8220
+ if (!workflow.response.ok) {
8221
+ throw new Error(`LinkedIn enrichment workflow returned ${workflow.response.status}: ${workflow.bodyText.slice(0, 300)}`);
8222
+ }
8223
+ linkedInUrlByContactId = normalizeWorkflowLinkedInUrlResult({
8224
+ parsedBody: workflow.parsedBody,
8225
+ contacts: contactsStillMissing
8226
+ });
8227
+ for (const row of enrichedRows) {
8228
+ if (row.found)
8229
+ continue;
8230
+ const profile = linkedInUrlByContactId.get(row.contactId);
8231
+ if (profile?.linkedinUrl) {
8232
+ row.linkedinUrl = profile.linkedinUrl;
8233
+ row.salesNavProfileUrl = profile.salesNavProfileUrl ?? row.salesNavProfileUrl ?? null;
8234
+ row.linkedinCompanyUrl = profile.linkedinCompanyUrl ?? row.linkedinCompanyUrl ?? null;
8235
+ row.salesNavCompanyUrl = profile.salesNavCompanyUrl ?? row.salesNavCompanyUrl ?? null;
8236
+ row.found = true;
8237
+ row.companyFound = Boolean(row.linkedinCompanyUrl || row.salesNavCompanyUrl);
8238
+ row.source = "workflow";
8239
+ row.companySource =
8240
+ row.linkedinCompanyUrl || row.salesNavCompanyUrl ? "workflow" : row.companySource ?? null;
8241
+ }
8242
+ }
8243
+ }
8244
+ catch (error) {
8245
+ writeProgress(`Skipping workflow profile enrichment: ${error instanceof Error ? error.message : String(error)}`);
8246
+ }
8247
+ }
8248
+ const serperContacts = directContacts.filter((contact) => enrichedRows.some((row) => row.contactId === contact.contact_id && !row.found));
8249
+ if (strategy.bulkMode && serperContacts.length > 0) {
8250
+ writeProgress(`Using bulk profile resolution strategy for ${serperContacts.length} remaining contacts.`);
8251
+ }
8252
+ const serperResults = await resolveSerperLinkedInProfilesInParallel({
8253
+ contacts: serperContacts.filter((contact) => !contact.isVariation),
8254
+ timeoutMs,
8255
+ concurrency: Math.min(strategy.serperConcurrency, serperContacts.length || 1),
8256
+ maxQueries: strategy.serperMaxQueries,
8257
+ overallBudgetMs: strategy.serperStageBudgetMs
8258
+ });
8259
+ for (const row of enrichedRows) {
8260
+ if (row.found)
8261
+ continue;
8262
+ const linkedinUrl = serperResults.get(row.contactId);
8263
+ if (!linkedinUrl)
8264
+ continue;
8265
+ row.linkedinUrl = linkedinUrl;
8266
+ row.found = true;
8267
+ row.source = "web-search";
8268
+ }
8269
+ const stillMissingAfterSerper = enrichedRows.filter((row) => !row.found);
8270
+ if (shouldAttemptBulkDirectProfileLookup({
8271
+ strategy,
8272
+ unresolvedRowCount: stillMissingAfterSerper.length
8273
+ })) {
8274
+ const bulkDirectCandidates = rankContactsForBulkDirectProfileLookup({
8275
+ contacts: directContacts.filter((contact) => stillMissingAfterSerper.some((row) => row.contactId === contact.contact_id)),
8276
+ rowsByContactId: rowByContactId,
8277
+ limit: strategy.bulkDirectProfileMaxRows
8278
+ });
8279
+ if (bulkDirectCandidates.length > 0) {
8280
+ writeProgress(`Using bulk direct profile follow-up for ${bulkDirectCandidates.length} high-signal unresolved contacts.`);
8281
+ try {
8282
+ directAttempted = true;
8283
+ const result = await invokeLinkedInUrlEnrichmentDirect({
8284
+ contacts: bulkDirectCandidates,
8285
+ timeoutMs: strategy.bulkDirectProfileTimeoutMs,
8286
+ perAttemptTimeoutMs: Math.min(strategy.bulkDirectProfileTimeoutMs, 2_500),
8287
+ perContactBudgetMs: strategy.bulkDirectProfileTimeoutMs
8288
+ });
8289
+ const directCompanyContextByKey = new Map((result.companyContexts ?? []).map((context) => [context.normalizedCompanyKey, context]));
8290
+ const bulkDirectByContactId = new Map(result.contacts.map((contact) => [
8291
+ contact.contact_id,
8292
+ {
8293
+ linkedinUrl: contact.linkedin_url ?? null,
8294
+ salesNavProfileUrl: contact.sales_nav_profile_url ?? null
8295
+ }
8296
+ ]));
8297
+ for (const row of enrichedRows) {
8298
+ if (row.found)
8299
+ continue;
8300
+ const profile = bulkDirectByContactId.get(row.contactId);
8301
+ if (profile?.linkedinUrl) {
8302
+ row.linkedinUrl = profile.linkedinUrl;
8303
+ row.salesNavProfileUrl = profile.salesNavProfileUrl ?? row.salesNavProfileUrl ?? null;
8304
+ row.found = true;
8305
+ row.source = "linkedin-direct";
8306
+ }
8307
+ const directContact = bulkDirectCandidates.find((candidate) => candidate.contact_id === row.contactId && !candidate.isVariation);
8308
+ const companyContext = directContact
8309
+ ? directCompanyContextByKey.get(buildDirectCompanyContextKey(directContact))
8310
+ : null;
8311
+ if (companyContext && !row.linkedinCompanyUrl) {
8312
+ row.linkedinCompanyUrl = companyContext.linkedinCompanyUrl ?? row.linkedinCompanyUrl ?? null;
8313
+ row.salesNavCompanyUrl = companyContext.salesNavCompanyUrl ?? row.salesNavCompanyUrl ?? null;
8314
+ row.companyFound = Boolean(row.linkedinCompanyUrl || row.salesNavCompanyUrl);
8315
+ row.companySource =
8316
+ row.linkedinCompanyUrl || row.salesNavCompanyUrl ? "linkedin-direct" : row.companySource ?? null;
8317
+ row.matchedCompanyName = companyContext.matchedCompanyName ?? row.matchedCompanyName ?? null;
8318
+ row.matchedCompanyEmployeeCount =
8319
+ companyContext.matchedCompanyEmployeeCount ?? row.matchedCompanyEmployeeCount ?? null;
8320
+ }
8321
+ }
8322
+ }
8323
+ catch (error) {
8324
+ const message = error instanceof Error ? error.message : String(error);
8325
+ if (!/Missing LinkedIn direct lookup session/i.test(message)) {
8326
+ writeProgress(`Skipping bulk direct profile follow-up: ${message}`);
8327
+ }
8328
+ }
8329
+ }
8330
+ }
6398
8331
  }
6399
8332
  const payload = {
6400
8333
  status: "ok",
@@ -6404,8 +8337,23 @@ program
6404
8337
  companiesFound: enrichedRows.filter((row) => row.companyFound).length,
6405
8338
  directAttempted,
6406
8339
  workflowAttempted,
8340
+ bulkMode: strategy.bulkMode,
6407
8341
  rows: enrichedRows
6408
8342
  };
8343
+ for (const row of enrichedRows) {
8344
+ const contact = contactById.get(row.contactId);
8345
+ upsertLinkedInProfileHitCacheEntry({
8346
+ fullName: row.fullName,
8347
+ companyName: row.companyName,
8348
+ email: contact?.email,
8349
+ contactId: row.contactId,
8350
+ linkedinUrl: row.linkedinUrl ?? null,
8351
+ salesNavProfileUrl: row.salesNavProfileUrl ?? null,
8352
+ linkedinCompanyUrl: row.linkedinCompanyUrl ?? null,
8353
+ salesNavCompanyUrl: row.salesNavCompanyUrl ?? null
8354
+ });
8355
+ }
8356
+ await persistLinkedInProfileHitCache();
6409
8357
  if (options.out) {
6410
8358
  await writeJsonFile(options.out, payload);
6411
8359
  }
@@ -6743,12 +8691,14 @@ program
6743
8691
  });
6744
8692
  program
6745
8693
  .command("leads:generate")
6746
- .description("Generate leads for a target account or from fallback seeds.")
8694
+ .description("Generate leads from your Salesprompter workspace when authenticated, or from fallback seeds.")
6747
8695
  .requiredOption("--icp <path>", "Path to ICP JSON")
6748
8696
  .option("--count <number>", "Number of leads to generate", "10")
6749
8697
  .option("--domain <domain>", "Target a specific company domain like company.com")
6750
8698
  .option("--company-domain <domain>", "Deprecated alias for --domain")
6751
8699
  .option("--company-name <name>", "Optional company name override for a targeted domain")
8700
+ .option("--linkedin-company-page <url>", "LinkedIn company page to target when the domain is unknown")
8701
+ .option("--source <source>", "auto|workspace|fallback", "auto")
6752
8702
  .requiredOption("--out <path>", "Output file path")
6753
8703
  .action(async (options) => {
6754
8704
  const icp = await readJsonFile(options.icp, IcpSchema);
@@ -6756,9 +8706,15 @@ program
6756
8706
  const domain = options.domain ?? options.companyDomain;
6757
8707
  const target = {
6758
8708
  companyDomain: domain,
6759
- companyName: options.companyName
8709
+ companyName: options.companyName,
8710
+ linkedinCompanyPage: options.linkedinCompanyPage
6760
8711
  };
6761
- const result = await leadProvider.generateLeads(icp, count, target);
8712
+ const result = await generateLeadsForCommand({
8713
+ icp,
8714
+ count,
8715
+ target,
8716
+ source: options.source
8717
+ });
6762
8718
  await writeJsonFile(options.out, result.leads);
6763
8719
  printOutput({
6764
8720
  status: "ok",
@@ -6803,6 +8759,8 @@ program
6803
8759
  .option("--domain <domain>", "Target a specific company domain like company.com")
6804
8760
  .option("--company-domain <domain>", "Deprecated alias for --domain")
6805
8761
  .option("--company-name <name>", "Optional company name override for a targeted domain")
8762
+ .option("--linkedin-company-page <url>", "LinkedIn company page to target when the domain is unknown")
8763
+ .option("--source <source>", "auto|workspace|fallback", "auto")
6806
8764
  .option("--out-prefix <path>", "Output path prefix (writes <prefix>-leads.json, <prefix>-enriched.json, <prefix>-scored.json)", "./data/leads-pipeline")
6807
8765
  .action(async (options) => {
6808
8766
  const icp = await readJsonFile(options.icp, IcpSchema);
@@ -6810,13 +8768,19 @@ program
6810
8768
  const domain = options.domain ?? options.companyDomain;
6811
8769
  const target = {
6812
8770
  companyDomain: domain,
6813
- companyName: options.companyName
8771
+ companyName: options.companyName,
8772
+ linkedinCompanyPage: options.linkedinCompanyPage
6814
8773
  };
6815
8774
  const outPrefix = String(options.outPrefix);
6816
8775
  const leadsOut = `${outPrefix}-leads.json`;
6817
8776
  const enrichedOut = `${outPrefix}-enriched.json`;
6818
8777
  const scoredOut = `${outPrefix}-scored.json`;
6819
- const generated = await leadProvider.generateLeads(icp, count, target);
8778
+ const generated = await generateLeadsForCommand({
8779
+ icp,
8780
+ count,
8781
+ target,
8782
+ source: options.source
8783
+ });
6820
8784
  await writeJsonFile(leadsOut, generated.leads);
6821
8785
  const enriched = await enrichmentProvider.enrichLeads(generated.leads);
6822
8786
  await writeJsonFile(enrichedOut, enriched);
@@ -7554,6 +9518,7 @@ program
7554
9518
  .option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
7555
9519
  .option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
7556
9520
  .option("--slice-preset <name>", "Slice preset label stored with the export runs", "human-resources-crawl")
9521
+ .option("--client-id <number>", "Client id used to generate and store the legacy Neon lead list projection")
7557
9522
  .option("--max-split-depth <number>", "Maximum number of adaptive split dimensions to use", "6")
7558
9523
  .option("--max-slices <number>", "Safety cap for total claimed slices in this invocation", "1000")
7559
9524
  .option("--max-retries <number>", "Retries for non-splitting export failures", "3")
@@ -7572,6 +9537,7 @@ program
7572
9537
  const jobId = z.string().uuid().optional().parse(options.jobId);
7573
9538
  const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
7574
9539
  const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
9540
+ const clientId = parseOptionalSalesNavigatorClientId(options.clientId);
7575
9541
  const maxSplitDepth = z.coerce.number().int().min(1).max(6).parse(options.maxSplitDepth);
7576
9542
  const maxSlices = z.coerce.number().int().min(1).max(10000).parse(options.maxSlices);
7577
9543
  const maxRetries = z.coerce.number().int().min(0).max(5).parse(options.maxRetries);
@@ -7591,6 +9557,7 @@ program
7591
9557
  jobId: jobId ?? null,
7592
9558
  maxResultsPerSearch,
7593
9559
  numberOfProfiles,
9560
+ clientId,
7594
9561
  slicePreset: options.slicePreset,
7595
9562
  maxSplitDepth,
7596
9563
  maxSlices,
@@ -7691,6 +9658,7 @@ program
7691
9658
  traceId: logger.traceId,
7692
9659
  command: {
7693
9660
  sourceQueryUrl: queryUrl,
9661
+ clientId,
7694
9662
  slicePreset: options.slicePreset,
7695
9663
  maxResultsPerSearch,
7696
9664
  numberOfProfiles,
@@ -7712,6 +9680,7 @@ program
7712
9680
  splitTrail: seed.splitTrail,
7713
9681
  rawPayload: {
7714
9682
  workflow: "salesnav:crawl",
9683
+ clientId,
7715
9684
  traceId: logger.traceId
7716
9685
  }
7717
9686
  }
@@ -7751,6 +9720,7 @@ program
7751
9720
  idlePollSeconds,
7752
9721
  idleMaxPolls,
7753
9722
  parallelExports,
9723
+ clientId,
7754
9724
  traceId: logger.traceId,
7755
9725
  logger
7756
9726
  });
@@ -7831,6 +9801,43 @@ program
7831
9801
  recentEvents
7832
9802
  });
7833
9803
  });
9804
+ program
9805
+ .command("phantombuster:containers:sync")
9806
+ .alias("pb:containers:sync")
9807
+ .description("Fetch Phantombuster containers for configured agents and store them in Neon.")
9808
+ .option("--agent-id <id>", "Phantombuster agent id to sync. Repeat to sync multiple agents.", collectStringOptionValue, [])
9809
+ .option("--limit <number>", "Maximum containers to fetch per Phantombuster page", "100")
9810
+ .option("--max-pages <number>", "Maximum Phantombuster pages to fetch per agent", "50")
9811
+ .option("--mode <mode>", "Phantombuster container mode: all or finalized", "all")
9812
+ .option("--before-ended-at <iso>", "Only fetch containers that ended before this ISO timestamp")
9813
+ .option("--metadata-only", "Store container metadata without fetching output and result objects", false)
9814
+ .option("--out <path>", "Optional local JSON output path")
9815
+ .action(async (options) => {
9816
+ const agentIds = z.array(z.string().min(1)).parse(options.agentId);
9817
+ const limit = z.coerce.number().int().min(1).max(500).parse(options.limit);
9818
+ const maxPages = z.coerce.number().int().min(1).max(500).parse(options.maxPages);
9819
+ const mode = z.enum(["all", "finalized"]).parse(options.mode);
9820
+ const beforeEndedAt = options.beforeEndedAt
9821
+ ? z.string().datetime().parse(options.beforeEndedAt)
9822
+ : undefined;
9823
+ const session = await requireAuthSession();
9824
+ const result = await syncPhantombusterContainersViaApp(session, {
9825
+ agentIds: agentIds.length > 0 ? agentIds : undefined,
9826
+ limit,
9827
+ maxPages,
9828
+ mode,
9829
+ beforeEndedAt,
9830
+ includeResults: !options.metadataOnly
9831
+ });
9832
+ const payload = {
9833
+ ...result,
9834
+ dryRun: false
9835
+ };
9836
+ if (options.out) {
9837
+ await writeJsonFile(options.out, payload);
9838
+ }
9839
+ printOutput(payload);
9840
+ });
7834
9841
  program
7835
9842
  .command("salesnav:export")
7836
9843
  .alias("search:export")
@@ -7839,12 +9846,18 @@ program
7839
9846
  .option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
7840
9847
  .option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
7841
9848
  .option("--slice-preset <name>", "Slice preset label stored with the export run", "human-resources-default")
9849
+ .option("--client-id <number>", "Client id used to generate and store the legacy Neon lead list projection")
9850
+ .option("--agent-busy-wait-seconds <number>", "Seconds to wait before retrying when the export agent is already busy", "30")
9851
+ .option("--agent-busy-max-waits <number>", "How many busy-agent waits to tolerate before failing the export", "20")
7842
9852
  .option("--out <path>", "Optional local JSON output path")
7843
9853
  .option("--dry-run", "Only generate sliced query URLs without exporting them", false)
7844
9854
  .action(async (options) => {
7845
9855
  const queryUrls = z.array(z.string().url()).min(1).parse(options.queryUrl);
7846
9856
  const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
7847
9857
  const numberOfProfiles = z.coerce.number().int().min(1).max(2500).parse(options.numberOfProfiles);
9858
+ const agentBusyWaitSeconds = z.coerce.number().int().min(1).max(300).parse(options.agentBusyWaitSeconds);
9859
+ const agentBusyMaxWaits = z.coerce.number().int().min(0).max(100).parse(options.agentBusyMaxWaits);
9860
+ const clientId = parseOptionalSalesNavigatorClientId(options.clientId);
7848
9861
  const prepared = queryUrls.map((queryUrl) => buildSalesNavigatorPeopleSlice(queryUrl));
7849
9862
  const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
7850
9863
  if (effectiveDryRun) {
@@ -7866,10 +9879,10 @@ program
7866
9879
  printOutput(payload);
7867
9880
  return;
7868
9881
  }
7869
- const session = await requireAuthSession();
9882
+ let session = await requireAuthSession();
7870
9883
  const exported = [];
7871
9884
  for (const item of prepared) {
7872
- const result = await runSalesNavigatorExport(session, {
9885
+ const result = await runSalesNavigatorExportWithAgentWait(session, {
7873
9886
  sourceQueryUrl: item.sourceQueryUrl,
7874
9887
  slicedQueryUrl: item.slicedQueryUrl,
7875
9888
  appliedFilters: item.appliedFilters,
@@ -7878,12 +9891,17 @@ program
7878
9891
  slicePreset: options.slicePreset,
7879
9892
  rawPayload: {
7880
9893
  workflow: "salesnav:export",
9894
+ clientId,
7881
9895
  sourceQueryUrl: item.sourceQueryUrl,
7882
9896
  slicedQueryUrl: item.slicedQueryUrl,
7883
9897
  appliedFilters: item.appliedFilters
7884
9898
  }
9899
+ }, {
9900
+ waitSeconds: agentBusyWaitSeconds,
9901
+ maxWaits: agentBusyMaxWaits
7885
9902
  });
7886
9903
  exported.push(result);
9904
+ session = await requireAuthSession();
7887
9905
  }
7888
9906
  const payload = {
7889
9907
  status: "ok",