salesprompter-cli 0.1.23 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -13
- package/dist/auth.js +2 -2
- package/dist/cli.js +1373 -27
- package/dist/hunter-emails.js +291 -0
- package/dist/linkedin-companies.js +550 -0
- package/dist/linkedin-products.js +68 -18
- package/dist/linkedin-session.js +13 -3
- package/dist/sales-navigator.js +15 -4
- package/package.json +8 -7
package/dist/cli.js
CHANGED
|
@@ -15,12 +15,13 @@ import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRo
|
|
|
15
15
|
import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
|
|
16
16
|
import { auditDomainDecisions, buildDomainfinderBacklogQueries, buildDomainfinderCandidatesSql, buildDomainfinderInputSql, buildDomainfinderWritebackSql, buildExistingDomainRepairSql, buildExistingDomainAuditQueries, compareDomainSelectionStrategies, selectBestDomains } from "./domainfinder.js";
|
|
17
17
|
import { buildDeelSalesNavCsvHeader, buildDeelSalesNavCsvLines, isDeelRelevantSalesNavTitle, normalizeDeelSalesNavRow } from "./deel-salesnav.js";
|
|
18
|
-
import { buildDeelOutreachExportSql, buildDeelOutreachPack, normalizeDeelOutreachRows } from "./deel-outreach.js";
|
|
18
|
+
import { buildDeelLeadPoolContactSql, buildDeelOutreachExportSql, buildDeelOutreachPack, normalizeDeelOutreachRows } from "./deel-outreach.js";
|
|
19
19
|
import { buildDirectPathLeadExportSql, normalizeDirectPathRows, segmentDirectPathRows } from "./direct-path.js";
|
|
20
20
|
import { AccountLeadProvider, DryRunSyncProvider, HeuristicCompanyProvider, HeuristicEnrichmentProvider, HeuristicPeopleSearchProvider, HeuristicScoringProvider, RoutedSyncProvider } from "./engine.js";
|
|
21
21
|
import { analyzeHistoricalQueries } from "./historical-queries.js";
|
|
22
22
|
import { buildHistoricalVendorIcp, buildVendorIcp } from "./icp-templates.js";
|
|
23
23
|
import { InstantlySyncProvider } from "./instantly.js";
|
|
24
|
+
import { backfillLinkedInCompanies } from "./linkedin-companies.js";
|
|
24
25
|
import { crawlLinkedInProductCategory } from "./linkedin-products.js";
|
|
25
26
|
import { claimValidatedSalesNavigatorSessionCookieForCli } from "./linkedin-session.js";
|
|
26
27
|
import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
|
|
@@ -56,6 +57,27 @@ const LinkedInProductIngestResponseSchema = z.object({
|
|
|
56
57
|
upserted: z.number().int().nonnegative(),
|
|
57
58
|
totalInCatalog: z.number().int().nonnegative().optional()
|
|
58
59
|
});
|
|
60
|
+
const LinkedInCompanyBackfillLaunchResponseSchema = z.object({
|
|
61
|
+
clientId: z.number().int().positive(),
|
|
62
|
+
launched: z.boolean(),
|
|
63
|
+
agentId: z.string().min(1),
|
|
64
|
+
webhookUrl: z.string().url(),
|
|
65
|
+
inputUrl: z.string().url().nullable(),
|
|
66
|
+
containerId: z.string().min(1).nullable(),
|
|
67
|
+
candidates: z.array(z.object({
|
|
68
|
+
companyId: z.number().int().positive(),
|
|
69
|
+
companyUrl: z.string().url(),
|
|
70
|
+
companyName: z.string().nullable().optional(),
|
|
71
|
+
companyFilter: z.string().nullable().optional()
|
|
72
|
+
}))
|
|
73
|
+
});
|
|
74
|
+
const LinkedInCompanyBackfillStatusResponseSchema = z.object({
|
|
75
|
+
status: z.literal("ok"),
|
|
76
|
+
containerId: z.string().min(1),
|
|
77
|
+
running: z.boolean(),
|
|
78
|
+
processed: z.boolean(),
|
|
79
|
+
remaining: z.number().int().nonnegative()
|
|
80
|
+
});
|
|
59
81
|
const SalesNavigatorLaunchDiagnosticsSchema = z.object({
|
|
60
82
|
orderedCandidateAgentIds: z.array(z.string().min(1)),
|
|
61
83
|
runningAgentIds: z.array(z.string().min(1)),
|
|
@@ -214,6 +236,40 @@ const SalesNavigatorCrawlReportResponseSchema = z.object({
|
|
|
214
236
|
status: z.literal("ok"),
|
|
215
237
|
job: SalesNavigatorCrawlJobSummarySchema
|
|
216
238
|
});
|
|
239
|
+
const helpAliasByCommandName = new Map([
|
|
240
|
+
["contacts:find-linkedin-urls", "contacts:resolve-profiles"],
|
|
241
|
+
["linkedin-companies:backfill", "companies:enrich"],
|
|
242
|
+
["linkedin-products:scrape", "market:scrape"],
|
|
243
|
+
["salesnav:from-product-category", "leads:discover"],
|
|
244
|
+
["salesnav:crawl", "search:run"],
|
|
245
|
+
["salesnav:crawl:status", "search:status"],
|
|
246
|
+
["salesnav:export", "search:export"],
|
|
247
|
+
["salesnav:count", "search:count"]
|
|
248
|
+
]);
|
|
249
|
+
const helpVisibleCommandNames = new Set([
|
|
250
|
+
"auth:login",
|
|
251
|
+
"wizard",
|
|
252
|
+
"auth:whoami",
|
|
253
|
+
"llm:ready",
|
|
254
|
+
"contacts:find-linkedin-urls",
|
|
255
|
+
"auth:logout",
|
|
256
|
+
"account:resolve",
|
|
257
|
+
"icp:define",
|
|
258
|
+
"icp:vendor",
|
|
259
|
+
"leads:generate",
|
|
260
|
+
"leads:enrich",
|
|
261
|
+
"leads:score",
|
|
262
|
+
"leads:pipeline",
|
|
263
|
+
"sync:crm",
|
|
264
|
+
"sync:outreach",
|
|
265
|
+
"linkedin-companies:backfill",
|
|
266
|
+
"linkedin-products:scrape",
|
|
267
|
+
"salesnav:from-product-category",
|
|
268
|
+
"salesnav:crawl",
|
|
269
|
+
"salesnav:crawl:status",
|
|
270
|
+
"salesnav:export",
|
|
271
|
+
"salesnav:count"
|
|
272
|
+
]);
|
|
217
273
|
function printOutput(value) {
|
|
218
274
|
if (runtimeOutputOptions.quiet) {
|
|
219
275
|
return;
|
|
@@ -227,6 +283,13 @@ function writeProgress(message) {
|
|
|
227
283
|
}
|
|
228
284
|
process.stderr.write(`${message}\n`);
|
|
229
285
|
}
|
|
286
|
+
function formatHelpArgumentTerm(argument) {
|
|
287
|
+
const term = argument.name();
|
|
288
|
+
if (argument.variadic) {
|
|
289
|
+
return argument.required ? `<${term}...>` : `[${term}...]`;
|
|
290
|
+
}
|
|
291
|
+
return argument.required ? `<${term}>` : `[${term}]`;
|
|
292
|
+
}
|
|
230
293
|
function applyGlobalOutputOptions(actionCommand) {
|
|
231
294
|
const globalOptions = actionCommand.optsWithGlobals();
|
|
232
295
|
runtimeOutputOptions.json = Boolean(globalOptions.json);
|
|
@@ -331,6 +394,27 @@ function canPromptForInteractiveLogin() {
|
|
|
331
394
|
}
|
|
332
395
|
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
333
396
|
}
|
|
397
|
+
function resolveNonInteractiveAuthToken(env = process.env) {
|
|
398
|
+
const token = env.SALESPROMPTER_TOKEN?.trim() ||
|
|
399
|
+
env.SALESPROMPTER_API_TOKEN?.trim() ||
|
|
400
|
+
env.SALESPROMPTER_AUTH_TOKEN?.trim() ||
|
|
401
|
+
"";
|
|
402
|
+
return token.length > 0 ? token : null;
|
|
403
|
+
}
|
|
404
|
+
async function readAllStdin() {
|
|
405
|
+
if (process.stdin.isTTY) {
|
|
406
|
+
return "";
|
|
407
|
+
}
|
|
408
|
+
return await new Promise((resolve, reject) => {
|
|
409
|
+
let data = "";
|
|
410
|
+
process.stdin.setEncoding("utf8");
|
|
411
|
+
process.stdin.on("data", (chunk) => {
|
|
412
|
+
data += chunk;
|
|
413
|
+
});
|
|
414
|
+
process.stdin.on("end", () => resolve(data));
|
|
415
|
+
process.stdin.on("error", reject);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
334
418
|
async function ensureInteractiveAuthSession(apiUrl) {
|
|
335
419
|
if (!canPromptForInteractiveLogin()) {
|
|
336
420
|
return;
|
|
@@ -347,6 +431,523 @@ function shellQuote(value) {
|
|
|
347
431
|
}
|
|
348
432
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
349
433
|
}
|
|
434
|
+
function splitLooseDelimitedLine(line, delimiter) {
|
|
435
|
+
if (delimiter !== ",") {
|
|
436
|
+
return line.split(delimiter);
|
|
437
|
+
}
|
|
438
|
+
const values = [];
|
|
439
|
+
let current = "";
|
|
440
|
+
let inQuotes = false;
|
|
441
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
442
|
+
const character = line[index];
|
|
443
|
+
const nextCharacter = line[index + 1];
|
|
444
|
+
if (character === '"') {
|
|
445
|
+
if (inQuotes && nextCharacter === '"') {
|
|
446
|
+
current += '"';
|
|
447
|
+
index += 1;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
inQuotes = !inQuotes;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (character === "," && !inQuotes) {
|
|
454
|
+
values.push(current);
|
|
455
|
+
current = "";
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
current += character;
|
|
459
|
+
}
|
|
460
|
+
values.push(current);
|
|
461
|
+
return values;
|
|
462
|
+
}
|
|
463
|
+
function detectLooseDelimiter(sampleLine) {
|
|
464
|
+
if (sampleLine.includes("\t")) {
|
|
465
|
+
return "\t";
|
|
466
|
+
}
|
|
467
|
+
if (sampleLine.includes(";")) {
|
|
468
|
+
return ";";
|
|
469
|
+
}
|
|
470
|
+
return ",";
|
|
471
|
+
}
|
|
472
|
+
function normalizeLooseMatchText(value) {
|
|
473
|
+
return String(value ?? "")
|
|
474
|
+
.normalize("NFKD")
|
|
475
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
476
|
+
.replace(/ß/g, "ss")
|
|
477
|
+
.toLowerCase()
|
|
478
|
+
.replace(/&/g, " and ")
|
|
479
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
480
|
+
.replace(/\b(gmbh|mbh|co|kg|ohg|ag|ltd|limited|ges|gesellschaft|m|b|h|und|and)\b/g, " ")
|
|
481
|
+
.replace(/\s+/g, " ")
|
|
482
|
+
.trim();
|
|
483
|
+
}
|
|
484
|
+
function normalizeLookupWhitespace(value) {
|
|
485
|
+
return String(value ?? "")
|
|
486
|
+
.replace(/[\u2010-\u2015]/g, "-")
|
|
487
|
+
.replace(/\s+/g, " ")
|
|
488
|
+
.trim();
|
|
489
|
+
}
|
|
490
|
+
function stripLookupHonorifics(value) {
|
|
491
|
+
return value.replace(/^(dr\.?|dipl\.-?ing\.?|prof\.?|ing\.?|mag\.?)\s+/i, "").trim();
|
|
492
|
+
}
|
|
493
|
+
function normalizeLookupCompanyForSearch(value) {
|
|
494
|
+
return normalizeLookupWhitespace(value)
|
|
495
|
+
.replace(/[+]/g, " ")
|
|
496
|
+
.replace(/\s*-\s*/g, " ")
|
|
497
|
+
.replace(/[.,]/g, " ")
|
|
498
|
+
.replace(/\s+/g, " ")
|
|
499
|
+
.trim();
|
|
500
|
+
}
|
|
501
|
+
function splitLookupFullName(fullName) {
|
|
502
|
+
const parts = normalizeLookupWhitespace(fullName).split(" ").filter(Boolean);
|
|
503
|
+
return {
|
|
504
|
+
firstName: parts[0] ?? "",
|
|
505
|
+
lastName: parts.slice(1).join(" ")
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function buildSyntheticLookupEmail(contactId) {
|
|
509
|
+
return `linkedin-lookup+${contactId}@salesprompter.invalid`;
|
|
510
|
+
}
|
|
511
|
+
function looksLikeLookupCompanyRow(fullName, companyName) {
|
|
512
|
+
const fullNameComparable = normalizeLooseMatchText(fullName);
|
|
513
|
+
const companyComparable = normalizeLooseMatchText(companyName);
|
|
514
|
+
if (!fullNameComparable || !companyComparable) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
return (fullNameComparable === companyComparable ||
|
|
518
|
+
fullNameComparable.includes(companyComparable) ||
|
|
519
|
+
companyComparable.includes(fullNameComparable));
|
|
520
|
+
}
|
|
521
|
+
function parseLinkedInUrlLookupInput(content) {
|
|
522
|
+
const trimmed = content.trim();
|
|
523
|
+
if (!trimmed) {
|
|
524
|
+
return [];
|
|
525
|
+
}
|
|
526
|
+
if (trimmed.startsWith("[")) {
|
|
527
|
+
const parsed = z
|
|
528
|
+
.array(z.object({
|
|
529
|
+
clientId: z.union([z.string(), z.number()]).nullish(),
|
|
530
|
+
fullName: z.string().nullish(),
|
|
531
|
+
companyName: z.string().nullish(),
|
|
532
|
+
email: z.string().nullish(),
|
|
533
|
+
jobTitle: z.string().nullish()
|
|
534
|
+
}))
|
|
535
|
+
.parse(JSON.parse(trimmed));
|
|
536
|
+
return parsed
|
|
537
|
+
.map((row) => ({
|
|
538
|
+
clientId: row.clientId == null ? null : String(row.clientId).trim() || null,
|
|
539
|
+
fullName: row.fullName?.trim() ?? "",
|
|
540
|
+
companyName: row.companyName?.trim() ?? "",
|
|
541
|
+
email: row.email?.trim() || undefined,
|
|
542
|
+
jobTitle: row.jobTitle?.trim() || undefined
|
|
543
|
+
}))
|
|
544
|
+
.filter((row) => row.fullName.length > 0 || row.companyName.length > 0);
|
|
545
|
+
}
|
|
546
|
+
const lines = trimmed
|
|
547
|
+
.split(/\r?\n/)
|
|
548
|
+
.map((line) => line.trim())
|
|
549
|
+
.filter((line) => line.length > 0);
|
|
550
|
+
if (lines.length === 0) {
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
const delimiter = detectLooseDelimiter(lines[0] ?? "");
|
|
554
|
+
const headerValues = splitLooseDelimitedLine(lines[0] ?? "", delimiter).map((value) => value.trim().toLowerCase());
|
|
555
|
+
const hasHeader = headerValues.includes("fullname") ||
|
|
556
|
+
headerValues.includes("full_name") ||
|
|
557
|
+
headerValues.includes("companyname") ||
|
|
558
|
+
headerValues.includes("company_name");
|
|
559
|
+
const dataLines = hasHeader ? lines.slice(1) : lines;
|
|
560
|
+
const clientIdIndex = hasHeader
|
|
561
|
+
? headerValues.findIndex((value) => ["clientid", "client_id"].includes(value))
|
|
562
|
+
: 0;
|
|
563
|
+
const fullNameIndex = hasHeader
|
|
564
|
+
? headerValues.findIndex((value) => ["fullname", "full_name", "contact_name", "name"].includes(value))
|
|
565
|
+
: 1;
|
|
566
|
+
const companyNameIndex = hasHeader
|
|
567
|
+
? headerValues.findIndex((value) => ["companyname", "company_name"].includes(value))
|
|
568
|
+
: 2;
|
|
569
|
+
const emailIndex = hasHeader ? headerValues.findIndex((value) => value === "email") : -1;
|
|
570
|
+
const jobTitleIndex = hasHeader
|
|
571
|
+
? headerValues.findIndex((value) => ["jobtitle", "job_title", "title"].includes(value))
|
|
572
|
+
: -1;
|
|
573
|
+
return dataLines
|
|
574
|
+
.map((line) => splitLooseDelimitedLine(line, delimiter).map((value) => value.trim()))
|
|
575
|
+
.map((columns) => ({
|
|
576
|
+
clientId: clientIdIndex >= 0 ? columns[clientIdIndex] || null : null,
|
|
577
|
+
fullName: fullNameIndex >= 0 ? columns[fullNameIndex] || "" : "",
|
|
578
|
+
companyName: companyNameIndex >= 0 ? columns[companyNameIndex] || "" : "",
|
|
579
|
+
email: emailIndex >= 0 ? columns[emailIndex] || undefined : undefined,
|
|
580
|
+
jobTitle: jobTitleIndex >= 0 ? columns[jobTitleIndex] || undefined : undefined
|
|
581
|
+
}))
|
|
582
|
+
.filter((row) => row.fullName.length > 0 || row.companyName.length > 0);
|
|
583
|
+
}
|
|
584
|
+
function toLinkedInUrlLookupContacts(rows) {
|
|
585
|
+
return rows.flatMap((row, index) => {
|
|
586
|
+
const contactId = String(index + 1);
|
|
587
|
+
const syntheticEmail = row.email?.trim() || buildSyntheticLookupEmail(contactId);
|
|
588
|
+
const rawCompanyName = normalizeLookupWhitespace(row.companyName);
|
|
589
|
+
const cleanedCompanyName = normalizeLookupCompanyForSearch(rawCompanyName);
|
|
590
|
+
const rawFullName = normalizeLookupWhitespace(row.fullName);
|
|
591
|
+
if (looksLikeLookupCompanyRow(rawFullName, rawCompanyName)) {
|
|
592
|
+
return [
|
|
593
|
+
{
|
|
594
|
+
contact_id: contactId,
|
|
595
|
+
firstName: "",
|
|
596
|
+
lastName: "",
|
|
597
|
+
companyName: cleanedCompanyName,
|
|
598
|
+
companyNameOriginal: rawCompanyName || undefined,
|
|
599
|
+
email: syntheticEmail,
|
|
600
|
+
jobTitle: row.jobTitle
|
|
601
|
+
}
|
|
602
|
+
];
|
|
603
|
+
}
|
|
604
|
+
const cleanedFullName = stripLookupHonorifics(rawFullName);
|
|
605
|
+
const rawSplit = splitLookupFullName(rawFullName);
|
|
606
|
+
const cleanedSplit = splitLookupFullName(cleanedFullName);
|
|
607
|
+
const contacts = [
|
|
608
|
+
{
|
|
609
|
+
contact_id: contactId,
|
|
610
|
+
firstName: cleanedSplit.firstName,
|
|
611
|
+
lastName: cleanedSplit.lastName,
|
|
612
|
+
companyName: cleanedCompanyName,
|
|
613
|
+
companyNameOriginal: rawCompanyName || undefined,
|
|
614
|
+
email: syntheticEmail,
|
|
615
|
+
jobTitle: row.jobTitle
|
|
616
|
+
}
|
|
617
|
+
];
|
|
618
|
+
const rawDiffers = rawSplit.firstName !== cleanedSplit.firstName ||
|
|
619
|
+
rawSplit.lastName !== cleanedSplit.lastName;
|
|
620
|
+
if (rawDiffers && (rawSplit.firstName || rawSplit.lastName)) {
|
|
621
|
+
contacts.push({
|
|
622
|
+
contact_id: contactId,
|
|
623
|
+
firstName: rawSplit.firstName,
|
|
624
|
+
lastName: rawSplit.lastName,
|
|
625
|
+
companyName: cleanedCompanyName,
|
|
626
|
+
companyNameOriginal: rawCompanyName || undefined,
|
|
627
|
+
email: syntheticEmail,
|
|
628
|
+
jobTitle: row.jobTitle,
|
|
629
|
+
isVariation: true
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
return contacts;
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
function readPipedreamLinkedInEnrichmentConfig() {
|
|
636
|
+
const endpointUrl = process.env.SALESPROMPTER_LINKEDIN_ENRICHMENT_ENDPOINT_URL?.trim() ||
|
|
637
|
+
(process.env.PIPEDREAM_ENDPOINT_ID?.trim()
|
|
638
|
+
? `https://${process.env.PIPEDREAM_ENDPOINT_ID.trim()}.m.pipedream.net`
|
|
639
|
+
: "");
|
|
640
|
+
if (!endpointUrl) {
|
|
641
|
+
throw new Error("Missing LinkedIn enrichment endpoint. Set SALESPROMPTER_LINKEDIN_ENRICHMENT_ENDPOINT_URL or PIPEDREAM_ENDPOINT_ID.");
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
endpointUrl,
|
|
645
|
+
secret: process.env.PIPEDREAM_SECRET_KEY?.trim() || "",
|
|
646
|
+
clientId: process.env.PIPEDREAM_CLIENT_ID?.trim() || "",
|
|
647
|
+
projectId: process.env.PIPEDREAM_PROJECT_ID?.trim() || "",
|
|
648
|
+
projectEnvironment: process.env.PIPEDREAM_PROJECT_ENVIRONMENT?.trim() || ""
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
function deriveCsrfTokenFromCookie(cookie) {
|
|
652
|
+
const match = cookie.match(/JSESSIONID="?([^";]+)"?/i);
|
|
653
|
+
return match?.[1]?.trim() || "";
|
|
654
|
+
}
|
|
655
|
+
function readLinkedInDirectLookupConfig() {
|
|
656
|
+
const csrfToken = process.env.SALESPROMPTER_LINKEDIN_CSRF_TOKEN?.trim() ||
|
|
657
|
+
process.env.LINKEDIN_CSRF_TOKEN?.trim() ||
|
|
658
|
+
deriveCsrfTokenFromCookie(process.env.SALESPROMPTER_LINKEDIN_SALES_NAV_COOKIE?.trim() ||
|
|
659
|
+
process.env.LINKEDIN_SALES_NAV_COOKIE?.trim() ||
|
|
660
|
+
"");
|
|
661
|
+
const identity = process.env.SALESPROMPTER_LINKEDIN_X_LI_IDENTITY?.trim() ||
|
|
662
|
+
process.env.LINKEDIN_X_LI_IDENTITY?.trim() ||
|
|
663
|
+
"";
|
|
664
|
+
const cookie = process.env.SALESPROMPTER_LINKEDIN_SALES_NAV_COOKIE?.trim() ||
|
|
665
|
+
process.env.LINKEDIN_SALES_NAV_COOKIE?.trim() ||
|
|
666
|
+
"";
|
|
667
|
+
const userAgent = process.env.SALESPROMPTER_LINKEDIN_USER_AGENT?.trim() ||
|
|
668
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
|
|
669
|
+
if (!csrfToken || !identity || !cookie) {
|
|
670
|
+
throw new Error("Missing LinkedIn direct lookup tokens. Set LINKEDIN_CSRF_TOKEN, LINKEDIN_X_LI_IDENTITY, and LINKEDIN_SALES_NAV_COOKIE.");
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
csrfToken,
|
|
674
|
+
identity,
|
|
675
|
+
cookie,
|
|
676
|
+
userAgent
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
function generateLinkedInSessionId() {
|
|
680
|
+
return Array.from({ length: 22 }, () => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".charAt(Math.floor(Math.random() * 62))).join("");
|
|
681
|
+
}
|
|
682
|
+
function buildLinkedInSalesApiUrl(firstName, lastName, companyName) {
|
|
683
|
+
const baseUrl = process.env.SALESPROMPTER_LINKEDIN_SALES_API_BASE_URL?.trim() ||
|
|
684
|
+
"https://www.linkedin.com";
|
|
685
|
+
return `${baseUrl.replace(/\/+$/, "")}/sales-api/salesApiLeadSearch?q=searchQuery&query=(recentSearchParam:(id:${Date.now()},doLogHistory:true),filters:List((type:FIRST_NAME,values:List((text:${encodeURIComponent(firstName)},selectionType:INCLUDED))),(type:LAST_NAME,values:List((text:${encodeURIComponent(lastName)},selectionType:INCLUDED))),(type:CURRENT_COMPANY,values:List((text:${encodeURIComponent(companyName)},selectionType:INCLUDED)))))&start=0&count=25&trackingParam=(sessionId:${generateLinkedInSessionId()})&decorationId=com.linkedin.sales.deco.desktop.searchv2.LeadSearchResult-14`;
|
|
686
|
+
}
|
|
687
|
+
function extractLinkedInProfileUrlFromSalesApiElement(element) {
|
|
688
|
+
const entityUrn = typeof element?.entityUrn === "string" ? element.entityUrn : "";
|
|
689
|
+
const salesIdMatch = entityUrn.match(/\(([^,]+),/);
|
|
690
|
+
return salesIdMatch ? `https://www.linkedin.com/in/${salesIdMatch[1]}` : null;
|
|
691
|
+
}
|
|
692
|
+
async function invokeLinkedInUrlEnrichmentDirect(params) {
|
|
693
|
+
const config = readLinkedInDirectLookupConfig();
|
|
694
|
+
const groupedContacts = new Map();
|
|
695
|
+
for (const contact of params.contacts) {
|
|
696
|
+
const key = contact.email?.trim().toLowerCase() || `contact:${contact.contact_id}`;
|
|
697
|
+
const existing = groupedContacts.get(key) ?? [];
|
|
698
|
+
existing.push(contact);
|
|
699
|
+
groupedContacts.set(key, existing);
|
|
700
|
+
}
|
|
701
|
+
const results = [];
|
|
702
|
+
let rateLimited = false;
|
|
703
|
+
for (const variations of groupedContacts.values()) {
|
|
704
|
+
const primary = variations.find((contact) => !contact.isVariation) ?? variations[0];
|
|
705
|
+
const blankPerson = !primary?.firstName.trim() || !primary?.lastName.trim();
|
|
706
|
+
if (rateLimited) {
|
|
707
|
+
results.push({
|
|
708
|
+
contact_id: primary.contact_id,
|
|
709
|
+
linkedin_url: null,
|
|
710
|
+
error: "LinkedIn rate limit"
|
|
711
|
+
});
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (blankPerson) {
|
|
715
|
+
results.push({
|
|
716
|
+
contact_id: primary.contact_id,
|
|
717
|
+
linkedin_url: null,
|
|
718
|
+
error: "Skipped blank or company-only row"
|
|
719
|
+
});
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
let matchedUrl = null;
|
|
723
|
+
let lastError = null;
|
|
724
|
+
for (const candidate of variations) {
|
|
725
|
+
const controller = new AbortController();
|
|
726
|
+
const timeout = setTimeout(controller.abort.bind(controller), Math.min(params.timeoutMs, 20_000));
|
|
727
|
+
try {
|
|
728
|
+
const response = await fetch(buildLinkedInSalesApiUrl(candidate.firstName, candidate.lastName, candidate.companyName), {
|
|
729
|
+
method: "GET",
|
|
730
|
+
signal: controller.signal,
|
|
731
|
+
headers: {
|
|
732
|
+
accept: "*/*",
|
|
733
|
+
"accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
|
|
734
|
+
"csrf-token": config.csrfToken,
|
|
735
|
+
referer: "https://www.linkedin.com/sales/search/people",
|
|
736
|
+
"sec-fetch-dest": "empty",
|
|
737
|
+
"sec-fetch-mode": "cors",
|
|
738
|
+
"sec-fetch-site": "same-origin",
|
|
739
|
+
"user-agent": config.userAgent,
|
|
740
|
+
"x-li-identity": config.identity,
|
|
741
|
+
"x-li-lang": "en_US",
|
|
742
|
+
"x-restli-protocol-version": "2.0.0",
|
|
743
|
+
cookie: config.cookie
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
if (response.status === 429) {
|
|
747
|
+
rateLimited = true;
|
|
748
|
+
lastError = "LinkedIn rate limit";
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
if (!response.ok) {
|
|
752
|
+
lastError = `LinkedIn returned ${response.status}`;
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
const data = (await response.json());
|
|
756
|
+
const profilesFound = data.paging?.total ?? 0;
|
|
757
|
+
if (profilesFound > 0) {
|
|
758
|
+
matchedUrl = extractLinkedInProfileUrlFromSalesApiElement(data.elements?.[0]) ?? null;
|
|
759
|
+
if (matchedUrl) {
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
lastError = error instanceof Error ? error.message : "Unknown direct lookup error";
|
|
766
|
+
}
|
|
767
|
+
finally {
|
|
768
|
+
clearTimeout(timeout);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
results.push({
|
|
772
|
+
contact_id: primary.contact_id,
|
|
773
|
+
linkedin_url: matchedUrl,
|
|
774
|
+
error: matchedUrl ? null : lastError
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
success: true,
|
|
779
|
+
contacts: results
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
async function invokeLinkedInUrlEnrichmentWorkflow(params) {
|
|
783
|
+
const config = readPipedreamLinkedInEnrichmentConfig();
|
|
784
|
+
const endpoint = new URL(config.endpointUrl);
|
|
785
|
+
if (config.secret && !endpoint.searchParams.has("secret")) {
|
|
786
|
+
endpoint.searchParams.set("secret", config.secret);
|
|
787
|
+
}
|
|
788
|
+
const controller = new AbortController();
|
|
789
|
+
const timeout = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
790
|
+
const headers = {
|
|
791
|
+
"Content-Type": "application/json",
|
|
792
|
+
"x-pd-external-user-id": params.externalUserId
|
|
793
|
+
};
|
|
794
|
+
if (config.secret) {
|
|
795
|
+
headers.Authorization = `Bearer ${config.secret}`;
|
|
796
|
+
headers["x-pd-secret"] = config.secret;
|
|
797
|
+
headers["x-secret-key"] = config.secret;
|
|
798
|
+
}
|
|
799
|
+
if (config.clientId) {
|
|
800
|
+
headers["X-Client-ID"] = config.clientId;
|
|
801
|
+
}
|
|
802
|
+
if (config.projectId) {
|
|
803
|
+
headers["X-Project-ID"] = config.projectId;
|
|
804
|
+
}
|
|
805
|
+
if (config.projectEnvironment) {
|
|
806
|
+
headers["X-Environment"] = config.projectEnvironment;
|
|
807
|
+
headers["x-pd-environment"] = config.projectEnvironment;
|
|
808
|
+
}
|
|
809
|
+
const payload = {
|
|
810
|
+
action: "find_linkedin_urls",
|
|
811
|
+
workflow_target: "find_contact_linkedin_urls",
|
|
812
|
+
app_source: "salesprompter_cli",
|
|
813
|
+
integration: "hubspot",
|
|
814
|
+
integration_type: "hubspot",
|
|
815
|
+
trigger_source: "cli_bulk_linkedin_url_lookup",
|
|
816
|
+
contact_count: params.contacts.length,
|
|
817
|
+
contacts: params.contacts,
|
|
818
|
+
payload: {
|
|
819
|
+
contacts: params.contacts
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
try {
|
|
823
|
+
const response = await fetch(endpoint, {
|
|
824
|
+
method: "POST",
|
|
825
|
+
headers,
|
|
826
|
+
body: JSON.stringify(payload),
|
|
827
|
+
signal: controller.signal
|
|
828
|
+
});
|
|
829
|
+
const bodyText = await response.text();
|
|
830
|
+
let parsedBody = null;
|
|
831
|
+
try {
|
|
832
|
+
parsedBody = bodyText ? JSON.parse(bodyText) : null;
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
parsedBody = bodyText;
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
response,
|
|
839
|
+
bodyText,
|
|
840
|
+
parsedBody,
|
|
841
|
+
payload,
|
|
842
|
+
endpoint: endpoint.toString()
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
catch (error) {
|
|
846
|
+
if (error.name === "AbortError") {
|
|
847
|
+
throw new Error(`LinkedIn enrichment workflow timed out after ${params.timeoutMs}ms.`);
|
|
848
|
+
}
|
|
849
|
+
throw error;
|
|
850
|
+
}
|
|
851
|
+
finally {
|
|
852
|
+
clearTimeout(timeout);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
async function fetchSalesNavLookupCandidates(params) {
|
|
856
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
|
857
|
+
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
|
858
|
+
if (!supabaseUrl || !serviceRoleKey || !params.companyName.trim()) {
|
|
859
|
+
return [];
|
|
860
|
+
}
|
|
861
|
+
const supabase = createClient(supabaseUrl, serviceRoleKey, {
|
|
862
|
+
auth: {
|
|
863
|
+
autoRefreshToken: false,
|
|
864
|
+
persistSession: false
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
const mapRows = (rows) => rows.map((row) => ({
|
|
868
|
+
orgId: row.org_id == null ? null : String(row.org_id),
|
|
869
|
+
fullName: row.full_name == null ? null : String(row.full_name),
|
|
870
|
+
companyName: row.company_name == null ? null : String(row.company_name),
|
|
871
|
+
title: row.title == null ? null : String(row.title),
|
|
872
|
+
salesNavProfileUrl: row.sales_nav_profile_url == null ? null : String(row.sales_nav_profile_url),
|
|
873
|
+
linkedInProfileUrl: row.linkedin_profile_url == null ? null : String(row.linkedin_profile_url)
|
|
874
|
+
}));
|
|
875
|
+
const fetchRows = async (operator, value) => {
|
|
876
|
+
let query = supabase
|
|
877
|
+
.from("linkedin_sales_nav_people")
|
|
878
|
+
.select("org_id,full_name,company_name,title,sales_nav_profile_url,linkedin_profile_url")
|
|
879
|
+
.limit(10);
|
|
880
|
+
if (params.orgId?.trim()) {
|
|
881
|
+
query = query.eq("org_id", params.orgId.trim());
|
|
882
|
+
}
|
|
883
|
+
query = operator === "eq" ? query.eq("company_name", value) : query.ilike("company_name", value);
|
|
884
|
+
const response = await query;
|
|
885
|
+
if (response.error) {
|
|
886
|
+
throw new Error(`Sales Nav people lookup failed with ${response.error.message}`);
|
|
887
|
+
}
|
|
888
|
+
return mapRows((response.data ?? []));
|
|
889
|
+
};
|
|
890
|
+
const exactRows = await fetchRows("eq", params.companyName);
|
|
891
|
+
if (exactRows.length > 0) {
|
|
892
|
+
return exactRows;
|
|
893
|
+
}
|
|
894
|
+
return await fetchRows("ilike", `%${params.companyName}%`);
|
|
895
|
+
}
|
|
896
|
+
async function resolveLinkedInUrlsFromSalesNavRows(params) {
|
|
897
|
+
const results = [];
|
|
898
|
+
for (const [index, row] of params.rows.entries()) {
|
|
899
|
+
const candidates = await fetchSalesNavLookupCandidates({
|
|
900
|
+
companyName: row.companyName,
|
|
901
|
+
orgId: params.orgId
|
|
902
|
+
});
|
|
903
|
+
const normalizedName = normalizeLooseMatchText(row.fullName);
|
|
904
|
+
const normalizedCompany = normalizeLooseMatchText(row.companyName);
|
|
905
|
+
const ranked = candidates
|
|
906
|
+
.map((candidate) => {
|
|
907
|
+
const candidateName = normalizeLooseMatchText(candidate.fullName);
|
|
908
|
+
const candidateCompany = normalizeLooseMatchText(candidate.companyName);
|
|
909
|
+
let score = 0;
|
|
910
|
+
if (normalizedCompany && candidateCompany === normalizedCompany)
|
|
911
|
+
score += 100;
|
|
912
|
+
else if (normalizedCompany &&
|
|
913
|
+
(candidateCompany.includes(normalizedCompany) || normalizedCompany.includes(candidateCompany))) {
|
|
914
|
+
score += 60;
|
|
915
|
+
}
|
|
916
|
+
if (normalizedName && candidateName === normalizedName)
|
|
917
|
+
score += 100;
|
|
918
|
+
else if (normalizedName &&
|
|
919
|
+
(candidateName.includes(normalizedName) || normalizedName.includes(candidateName))) {
|
|
920
|
+
score += 50;
|
|
921
|
+
}
|
|
922
|
+
if (!normalizedName && candidateCompany) {
|
|
923
|
+
score += 5;
|
|
924
|
+
}
|
|
925
|
+
return { candidate, score };
|
|
926
|
+
})
|
|
927
|
+
.filter((entry) => entry.score >= (normalizedName ? 120 : 60))
|
|
928
|
+
.sort((left, right) => {
|
|
929
|
+
const leftUrl = left.candidate.linkedInProfileUrl ?? left.candidate.salesNavProfileUrl;
|
|
930
|
+
const rightUrl = right.candidate.linkedInProfileUrl ?? right.candidate.salesNavProfileUrl;
|
|
931
|
+
return right.score - left.score || Number(Boolean(rightUrl)) - Number(Boolean(leftUrl));
|
|
932
|
+
});
|
|
933
|
+
const best = ranked[0]?.candidate;
|
|
934
|
+
const linkedinUrl = best?.linkedInProfileUrl ?? best?.salesNavProfileUrl ?? null;
|
|
935
|
+
results.push({
|
|
936
|
+
clientId: row.clientId,
|
|
937
|
+
fullName: row.fullName,
|
|
938
|
+
companyName: row.companyName,
|
|
939
|
+
linkedinUrl,
|
|
940
|
+
found: Boolean(linkedinUrl),
|
|
941
|
+
contactId: String(index + 1),
|
|
942
|
+
source: linkedinUrl ? "salesnav-supabase" : null,
|
|
943
|
+
matchedFullName: best?.fullName ?? null,
|
|
944
|
+
matchedCompanyName: best?.companyName ?? null,
|
|
945
|
+
matchedTitle: best?.title ?? null,
|
|
946
|
+
matchedOrgId: best?.orgId ?? null
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
return results;
|
|
950
|
+
}
|
|
350
951
|
function buildCommandLine(args) {
|
|
351
952
|
return args.map((arg) => shellQuote(arg)).join(" ");
|
|
352
953
|
}
|
|
@@ -501,6 +1102,10 @@ function getOrgLabel(session) {
|
|
|
501
1102
|
}
|
|
502
1103
|
return label;
|
|
503
1104
|
}
|
|
1105
|
+
function resolveSessionOrgId(session) {
|
|
1106
|
+
const orgId = session.user.orgId?.trim();
|
|
1107
|
+
return orgId && orgId.length > 0 ? orgId : null;
|
|
1108
|
+
}
|
|
504
1109
|
function writeSessionSummary(session) {
|
|
505
1110
|
const identity = session.user.name?.trim()
|
|
506
1111
|
? `${session.user.name} (${session.user.email})`
|
|
@@ -742,6 +1347,50 @@ async function ensureWizardSession(options) {
|
|
|
742
1347
|
writeWizardLine();
|
|
743
1348
|
return result.session;
|
|
744
1349
|
}
|
|
1350
|
+
async function resolveLlmAuthReadiness() {
|
|
1351
|
+
const apiBaseUrl = process.env.SALESPROMPTER_API_BASE_URL?.trim() || "https://salesprompter.ai";
|
|
1352
|
+
const envToken = resolveNonInteractiveAuthToken(process.env);
|
|
1353
|
+
if (envToken) {
|
|
1354
|
+
try {
|
|
1355
|
+
const session = await loginWithToken(envToken, apiBaseUrl);
|
|
1356
|
+
return {
|
|
1357
|
+
ready: true,
|
|
1358
|
+
mode: "env_token",
|
|
1359
|
+
apiBaseUrl: session.apiBaseUrl,
|
|
1360
|
+
user: session.user,
|
|
1361
|
+
reason: null
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
catch (error) {
|
|
1365
|
+
return {
|
|
1366
|
+
ready: false,
|
|
1367
|
+
mode: "env_token",
|
|
1368
|
+
apiBaseUrl,
|
|
1369
|
+
user: null,
|
|
1370
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
try {
|
|
1375
|
+
const session = await requireAuthSession();
|
|
1376
|
+
return {
|
|
1377
|
+
ready: true,
|
|
1378
|
+
mode: "session",
|
|
1379
|
+
apiBaseUrl: session.apiBaseUrl,
|
|
1380
|
+
user: session.user,
|
|
1381
|
+
reason: null
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
catch (error) {
|
|
1385
|
+
return {
|
|
1386
|
+
ready: false,
|
|
1387
|
+
mode: "none",
|
|
1388
|
+
apiBaseUrl,
|
|
1389
|
+
user: null,
|
|
1390
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
745
1394
|
async function fetchWorkspaceLeadSearch(session, requestBody) {
|
|
746
1395
|
const response = await fetch(`${session.apiBaseUrl}/api/cli/leads/search`, {
|
|
747
1396
|
method: "POST",
|
|
@@ -802,10 +1451,14 @@ function decodeSalesNavigatorQueryParam(url) {
|
|
|
802
1451
|
async function createWorkflowLogger(options) {
|
|
803
1452
|
const traceId = options.traceId ?? buildWorkflowTraceId("salesprompter-cli");
|
|
804
1453
|
const logPath = options.logPath;
|
|
1454
|
+
let eventStore = options.eventStore ?? null;
|
|
805
1455
|
await mkdir(path.dirname(logPath), { recursive: true });
|
|
806
1456
|
return {
|
|
807
1457
|
traceId,
|
|
808
1458
|
logPath,
|
|
1459
|
+
setEventStore: (nextEventStore) => {
|
|
1460
|
+
eventStore = nextEventStore;
|
|
1461
|
+
},
|
|
809
1462
|
log: async (event, metadata = {}) => {
|
|
810
1463
|
const entry = {
|
|
811
1464
|
timestamp: new Date().toISOString(),
|
|
@@ -814,10 +1467,111 @@ async function createWorkflowLogger(options) {
|
|
|
814
1467
|
metadata
|
|
815
1468
|
};
|
|
816
1469
|
await appendFile(logPath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
1470
|
+
if (eventStore) {
|
|
1471
|
+
try {
|
|
1472
|
+
await eventStore.append({
|
|
1473
|
+
jobId: typeof metadata.jobId === "string" ? metadata.jobId : null,
|
|
1474
|
+
event,
|
|
1475
|
+
timestamp: entry.timestamp,
|
|
1476
|
+
traceId,
|
|
1477
|
+
source: "cli",
|
|
1478
|
+
metadata
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
catch (error) {
|
|
1482
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1483
|
+
writeProgress(`[${entry.timestamp}] salesnav.event_store.write_failed: ${message}`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
817
1486
|
writeProgress(`[${entry.timestamp}] ${event}`);
|
|
818
1487
|
}
|
|
819
1488
|
};
|
|
820
1489
|
}
|
|
1490
|
+
function resolveSalesNavigatorRunEventsTable(env = process.env) {
|
|
1491
|
+
return env.SALESPROMPTER_SALESNAV_RUN_EVENTS_TABLE?.trim() || "salesnav_crawl_run_events";
|
|
1492
|
+
}
|
|
1493
|
+
function resolveSalesNavigatorPhantomLaneLimit(env = process.env) {
|
|
1494
|
+
const raw = env.SALESPROMPTER_SALESNAV_PHANTOM_LANES?.trim();
|
|
1495
|
+
const parsed = raw ? Number(raw) : Number.NaN;
|
|
1496
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
1497
|
+
return 3;
|
|
1498
|
+
}
|
|
1499
|
+
return Math.max(1, Math.floor(parsed));
|
|
1500
|
+
}
|
|
1501
|
+
function isMissingSupabaseTableError(code, message) {
|
|
1502
|
+
const normalized = message.toLowerCase();
|
|
1503
|
+
return code === "42P01" || normalized.includes("does not exist");
|
|
1504
|
+
}
|
|
1505
|
+
async function createSalesNavigatorCrawlEventStore(options) {
|
|
1506
|
+
const config = (() => {
|
|
1507
|
+
try {
|
|
1508
|
+
return resolveSalesNavigatorSupabaseConfig(process.env);
|
|
1509
|
+
}
|
|
1510
|
+
catch {
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
})();
|
|
1514
|
+
if (!config) {
|
|
1515
|
+
return null;
|
|
1516
|
+
}
|
|
1517
|
+
const table = resolveSalesNavigatorRunEventsTable(process.env);
|
|
1518
|
+
const client = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
|
|
1519
|
+
auth: {
|
|
1520
|
+
persistSession: false,
|
|
1521
|
+
autoRefreshToken: false
|
|
1522
|
+
}
|
|
1523
|
+
});
|
|
1524
|
+
let writable = true;
|
|
1525
|
+
return {
|
|
1526
|
+
append: async (entry) => {
|
|
1527
|
+
if (!writable) {
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
const { error } = await client.from(table).insert({
|
|
1531
|
+
org_id: options.orgId,
|
|
1532
|
+
job_id: entry.jobId,
|
|
1533
|
+
event: entry.event,
|
|
1534
|
+
event_time: entry.timestamp,
|
|
1535
|
+
trace_id: entry.traceId,
|
|
1536
|
+
source: entry.source,
|
|
1537
|
+
payload: entry.metadata
|
|
1538
|
+
});
|
|
1539
|
+
if (!error) {
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
if (isMissingSupabaseTableError(error.code, error.message)) {
|
|
1543
|
+
writable = false;
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
throw new Error(`Failed to write crawl event to Supabase: ${error.message}`);
|
|
1547
|
+
},
|
|
1548
|
+
listRecentForJob: async (jobId, limit) => {
|
|
1549
|
+
const { data, error } = await client
|
|
1550
|
+
.from(table)
|
|
1551
|
+
.select("job_id,event,event_time,trace_id,source,payload")
|
|
1552
|
+
.eq("org_id", options.orgId)
|
|
1553
|
+
.eq("job_id", jobId)
|
|
1554
|
+
.order("event_time", { ascending: false })
|
|
1555
|
+
.limit(limit);
|
|
1556
|
+
if (error) {
|
|
1557
|
+
if (isMissingSupabaseTableError(error.code, error.message)) {
|
|
1558
|
+
return [];
|
|
1559
|
+
}
|
|
1560
|
+
throw new Error(`Failed to fetch crawl events from Supabase: ${error.message}`);
|
|
1561
|
+
}
|
|
1562
|
+
return (data ?? []).map((row) => ({
|
|
1563
|
+
jobId: typeof row.job_id === "string" ? row.job_id : null,
|
|
1564
|
+
event: typeof row.event === "string" ? row.event : "unknown",
|
|
1565
|
+
timestamp: typeof row.event_time === "string" ? row.event_time : new Date().toISOString(),
|
|
1566
|
+
traceId: typeof row.trace_id === "string" ? row.trace_id : null,
|
|
1567
|
+
source: "cli",
|
|
1568
|
+
metadata: row.payload && typeof row.payload === "object" && !Array.isArray(row.payload)
|
|
1569
|
+
? row.payload
|
|
1570
|
+
: {}
|
|
1571
|
+
}));
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
821
1575
|
function summarizeSalesNavigatorQuery(url, appliedFilters) {
|
|
822
1576
|
return {
|
|
823
1577
|
url,
|
|
@@ -1041,6 +1795,13 @@ async function runSalesNavigatorFromProductCategoryWorkflow(options) {
|
|
|
1041
1795
|
return { outPath, payload };
|
|
1042
1796
|
}
|
|
1043
1797
|
let session = await requireAuthSession();
|
|
1798
|
+
const sessionOrgId = resolveSessionOrgId(session);
|
|
1799
|
+
if (sessionOrgId) {
|
|
1800
|
+
const eventStore = await createSalesNavigatorCrawlEventStore({
|
|
1801
|
+
orgId: sessionOrgId
|
|
1802
|
+
});
|
|
1803
|
+
logger.setEventStore(eventStore);
|
|
1804
|
+
}
|
|
1044
1805
|
let uploaded = null;
|
|
1045
1806
|
if (!options.skipProductUpload) {
|
|
1046
1807
|
await logger.log("linkedin.catalog.upload.started", {
|
|
@@ -1155,7 +1916,57 @@ async function runSalesNavigatorFromProductCategoryWorkflow(options) {
|
|
|
1155
1916
|
summary: crawlSummary
|
|
1156
1917
|
});
|
|
1157
1918
|
}
|
|
1158
|
-
|
|
1919
|
+
let summary = buildSalesNavigatorWorkflowSummary(crawls);
|
|
1920
|
+
const sessionPoolBlocked = crawls.some((crawl) => crawl.lastOutcome?.errorCode === "blocked_no_valid_salesnav_session");
|
|
1921
|
+
if (sessionPoolBlocked && sessionOrgId) {
|
|
1922
|
+
try {
|
|
1923
|
+
await logger.log("salesnav.bigquery-fallback.started", {
|
|
1924
|
+
orgId: sessionOrgId,
|
|
1925
|
+
scope: "all-sales-people",
|
|
1926
|
+
reason: "blocked_no_valid_salesnav_session",
|
|
1927
|
+
mode: "silent"
|
|
1928
|
+
});
|
|
1929
|
+
const fallbackConfig = resolveSalesNavigatorHistoricalBackfillConfig(process.env);
|
|
1930
|
+
const fallbackSummary = await ensureSalesNavigatorPeopleCount({
|
|
1931
|
+
config: fallbackConfig,
|
|
1932
|
+
orgId: sessionOrgId,
|
|
1933
|
+
targetCount: Number.MAX_SAFE_INTEGER,
|
|
1934
|
+
scope: "all-sales-people",
|
|
1935
|
+
startOffset: 0,
|
|
1936
|
+
resumedFromHistory: false,
|
|
1937
|
+
windowSize: 2_500,
|
|
1938
|
+
maxWindows: 1,
|
|
1939
|
+
pageSize: salesNavigatorHistoricalBackfillDefaults.pageSize,
|
|
1940
|
+
upsertBatchSize: salesNavigatorHistoricalBackfillDefaults.upsertBatchSize,
|
|
1941
|
+
minUpsertBatchSize: salesNavigatorHistoricalBackfillDefaults.minUpsertBatchSize,
|
|
1942
|
+
maxUpsertRetries: salesNavigatorHistoricalBackfillDefaults.maxUpsertRetries,
|
|
1943
|
+
retryDelayMs: salesNavigatorHistoricalBackfillDefaults.retryDelayMs
|
|
1944
|
+
});
|
|
1945
|
+
const importedDelta = Math.max(0, fallbackSummary.currentCount - fallbackSummary.initialCount);
|
|
1946
|
+
if (importedDelta > 0) {
|
|
1947
|
+
summary = {
|
|
1948
|
+
...summary,
|
|
1949
|
+
workflowStatus: "completed",
|
|
1950
|
+
totalImportedPeople: summary.totalImportedPeople + importedDelta
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
await logger.log("salesnav.bigquery-fallback.completed", {
|
|
1954
|
+
orgId: sessionOrgId,
|
|
1955
|
+
status: fallbackSummary.status,
|
|
1956
|
+
completedWindows: fallbackSummary.completedWindows,
|
|
1957
|
+
initialCount: fallbackSummary.initialCount,
|
|
1958
|
+
currentCount: fallbackSummary.currentCount,
|
|
1959
|
+
importedDelta
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
catch (fallbackError) {
|
|
1963
|
+
await logger.log("salesnav.bigquery-fallback.failed", {
|
|
1964
|
+
orgId: sessionOrgId,
|
|
1965
|
+
message: fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
|
|
1966
|
+
stack: fallbackError instanceof Error ? fallbackError.stack ?? null : null
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1159
1970
|
const payload = {
|
|
1160
1971
|
status: "ok",
|
|
1161
1972
|
dryRun: false,
|
|
@@ -1283,6 +2094,26 @@ async function uploadLinkedInProductsCatalog(session, payload, batchSize = 100,
|
|
|
1283
2094
|
}
|
|
1284
2095
|
return { imported, upserted };
|
|
1285
2096
|
}
|
|
2097
|
+
async function launchLinkedInCompaniesBackfill(session, payload) {
|
|
2098
|
+
const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/linkedin-companies/backfill`, {
|
|
2099
|
+
method: 'POST',
|
|
2100
|
+
headers: {
|
|
2101
|
+
'Content-Type': 'application/json',
|
|
2102
|
+
Authorization: `Bearer ${currentSession.accessToken}`
|
|
2103
|
+
},
|
|
2104
|
+
body: JSON.stringify(payload)
|
|
2105
|
+
}), LinkedInCompanyBackfillLaunchResponseSchema);
|
|
2106
|
+
return value;
|
|
2107
|
+
}
|
|
2108
|
+
async function fetchLinkedInCompaniesBackfillStatus(session, payload) {
|
|
2109
|
+
const { value } = await fetchCliJson(session, (currentSession) => fetch(`${currentSession.apiBaseUrl}/api/cli/linkedin-companies/status?clientId=${encodeURIComponent(String(payload.clientId))}&containerId=${encodeURIComponent(payload.containerId)}`, {
|
|
2110
|
+
method: "GET",
|
|
2111
|
+
headers: {
|
|
2112
|
+
Authorization: `Bearer ${currentSession.accessToken}`
|
|
2113
|
+
}
|
|
2114
|
+
}), LinkedInCompanyBackfillStatusResponseSchema);
|
|
2115
|
+
return value;
|
|
2116
|
+
}
|
|
1286
2117
|
function serializeSalesNavigatorFiltersForApi(filters) {
|
|
1287
2118
|
return filters.map((filter) => ({
|
|
1288
2119
|
type: filter.type,
|
|
@@ -1526,6 +2357,55 @@ function isSalesNavigatorAgentBusyError(error) {
|
|
|
1526
2357
|
const message = error instanceof Error ? error.message : String(error);
|
|
1527
2358
|
return /parallel executions limit/i.test(message);
|
|
1528
2359
|
}
|
|
2360
|
+
async function drainLinkedInCompanyBackfill(session, payload) {
|
|
2361
|
+
let batches = 0;
|
|
2362
|
+
let startedCompanies = 0;
|
|
2363
|
+
let remaining = 0;
|
|
2364
|
+
let consecutiveBusyPolls = 0;
|
|
2365
|
+
for (;;) {
|
|
2366
|
+
let launched;
|
|
2367
|
+
try {
|
|
2368
|
+
launched = await launchLinkedInCompaniesBackfill(session, payload);
|
|
2369
|
+
}
|
|
2370
|
+
catch (error) {
|
|
2371
|
+
if (isSalesNavigatorAgentBusyError(error)) {
|
|
2372
|
+
consecutiveBusyPolls += 1;
|
|
2373
|
+
if (consecutiveBusyPolls === 1) {
|
|
2374
|
+
writeProgress("Company enrichment is already running. Waiting for the current batch to finish...");
|
|
2375
|
+
}
|
|
2376
|
+
await delay(30_000);
|
|
2377
|
+
continue;
|
|
2378
|
+
}
|
|
2379
|
+
throw error;
|
|
2380
|
+
}
|
|
2381
|
+
consecutiveBusyPolls = 0;
|
|
2382
|
+
if (!launched.launched || !launched.containerId) {
|
|
2383
|
+
return {
|
|
2384
|
+
completed: true,
|
|
2385
|
+
batches,
|
|
2386
|
+
startedCompanies,
|
|
2387
|
+
remaining: launched.candidates.length
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
batches += 1;
|
|
2391
|
+
startedCompanies += launched.candidates.length;
|
|
2392
|
+
writeProgress(`Started company enrichment batch ${batches} for ${launched.candidates.length} companies.`);
|
|
2393
|
+
for (;;) {
|
|
2394
|
+
const status = await fetchLinkedInCompaniesBackfillStatus(session, {
|
|
2395
|
+
clientId: payload.clientId,
|
|
2396
|
+
containerId: launched.containerId
|
|
2397
|
+
});
|
|
2398
|
+
remaining = status.remaining;
|
|
2399
|
+
if (!status.running && status.processed) {
|
|
2400
|
+
writeProgress(remaining > 0
|
|
2401
|
+
? `${remaining} companies still waiting. Starting the next batch...`
|
|
2402
|
+
: "Company enrichment finished.");
|
|
2403
|
+
break;
|
|
2404
|
+
}
|
|
2405
|
+
await delay(15_000);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
1529
2409
|
function isSalesNavigatorSessionError(error) {
|
|
1530
2410
|
if (error instanceof SalesNavigatorExportRequestError) {
|
|
1531
2411
|
if (error.errorCode === "invalid_session") {
|
|
@@ -1536,7 +2416,7 @@ function isSalesNavigatorSessionError(error) {
|
|
|
1536
2416
|
}
|
|
1537
2417
|
}
|
|
1538
2418
|
const message = error instanceof Error ? error.message : String(error);
|
|
1539
|
-
return /can't connect profile|sales navigator account|upsell|linkedin session invalid|linkedin_rate_limited|too many requests|rate.?limit|invalid session cookie/i.test(message);
|
|
2419
|
+
return /can't connect profile|sales navigator account|upsell|linkedin session invalid|linkedin_rate_limited|too many requests|rate.?limit|invalid session cookie|disconnected by linkedin|linkedin-disconnected-while-using-api|provide a new linkedin session cookie/i.test(message);
|
|
1540
2420
|
}
|
|
1541
2421
|
function isSalesNavigatorResultArtifactError(error) {
|
|
1542
2422
|
if (error instanceof SalesNavigatorExportRequestError && error.errorCode === "phantombuster_result_invalid") {
|
|
@@ -2165,7 +3045,23 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
|
|
|
2165
3045
|
let job = null;
|
|
2166
3046
|
let idlePollCount = 0;
|
|
2167
3047
|
let lastOutcome = null;
|
|
2168
|
-
const
|
|
3048
|
+
const phantomLaneLimit = resolveSalesNavigatorPhantomLaneLimit(process.env);
|
|
3049
|
+
const parallelExports = Math.max(1, Math.min(options.parallelExports, phantomLaneLimit));
|
|
3050
|
+
await options.logger?.log("salesnav.crawl.parallel.config", {
|
|
3051
|
+
requestedParallelExports: options.parallelExports,
|
|
3052
|
+
effectiveParallelExports: parallelExports,
|
|
3053
|
+
phantomLaneLimit
|
|
3054
|
+
});
|
|
3055
|
+
if (!runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
|
|
3056
|
+
process.stderr.write(`Sales Navigator parallel exports: requested=${options.parallelExports}, lane_limit=${phantomLaneLimit}, effective=${parallelExports}\n`);
|
|
3057
|
+
}
|
|
3058
|
+
if (parallelExports < options.parallelExports) {
|
|
3059
|
+
await options.logger?.log("salesnav.crawl.parallel.capped", {
|
|
3060
|
+
requestedParallelExports: options.parallelExports,
|
|
3061
|
+
effectiveParallelExports: parallelExports,
|
|
3062
|
+
phantomLaneLimit
|
|
3063
|
+
});
|
|
3064
|
+
}
|
|
2169
3065
|
const inFlight = new Map();
|
|
2170
3066
|
let nextSlot = 0;
|
|
2171
3067
|
let noMoreClaimableWork = false;
|
|
@@ -2435,13 +3331,13 @@ async function runProductMarketWizard(rl) {
|
|
|
2435
3331
|
writeWizardLine(` ${buildCommandLine(commandArgs)}`);
|
|
2436
3332
|
}
|
|
2437
3333
|
async function runVendorShortcutWizard(rl) {
|
|
2438
|
-
writeWizardSection("Built-in
|
|
3334
|
+
writeWizardSection("Built-in vendor shortcut", "Use a built-in vendor ICP template and search your workspace lead data.");
|
|
2439
3335
|
const reference = parseCompanyReference(await promptText(rl, "Which company shortcut should I use?", {
|
|
2440
3336
|
required: true
|
|
2441
3337
|
}));
|
|
2442
3338
|
writeWizardLine();
|
|
2443
3339
|
if (reference.vendorTemplate !== "deel") {
|
|
2444
|
-
throw new Error("The built-in shortcut
|
|
3340
|
+
throw new Error("The built-in shortcut currently supports Deel only. Use deel.com or the Deel LinkedIn company page.");
|
|
2445
3341
|
}
|
|
2446
3342
|
const market = await promptChoice(rl, "Where do you want to search?", [
|
|
2447
3343
|
{ value: "dach", label: "DACH", description: "Germany, Austria, Switzerland" },
|
|
@@ -2578,14 +3474,14 @@ async function runWizard(options) {
|
|
|
2578
3474
|
},
|
|
2579
3475
|
{
|
|
2580
3476
|
value: "reference-company",
|
|
2581
|
-
label: "Use
|
|
2582
|
-
description: "Generate the saved
|
|
2583
|
-
aliases: ["
|
|
3477
|
+
label: "Use a built-in vendor shortcut",
|
|
3478
|
+
description: "Generate the saved vendor ICP and search workspace leads",
|
|
3479
|
+
aliases: ["vendor", "shortcut", "vendor template", "quick template"]
|
|
2584
3480
|
},
|
|
2585
3481
|
{
|
|
2586
3482
|
value: "target-company",
|
|
2587
3483
|
label: "Find people at a specific company",
|
|
2588
|
-
description: "Example: find people at
|
|
3484
|
+
description: "Example: find people at company.com",
|
|
2589
3485
|
aliases: ["target company", "company", "find people", "people at a company", "lead generation"]
|
|
2590
3486
|
},
|
|
2591
3487
|
{
|
|
@@ -2750,10 +3646,33 @@ async function fetchHistoricalQueryRows(tables) {
|
|
|
2750
3646
|
}
|
|
2751
3647
|
program
|
|
2752
3648
|
.name("salesprompter")
|
|
2753
|
-
.description("Sales workflow CLI for
|
|
3649
|
+
.description("Sales workflow CLI for guided lead generation, enrichment, scoring, and sync.")
|
|
2754
3650
|
.version(packageVersion)
|
|
2755
3651
|
.option("--json", "Emit compact machine-readable JSON output", false)
|
|
2756
3652
|
.option("--quiet", "Suppress successful stdout output", false);
|
|
3653
|
+
program.configureHelp({
|
|
3654
|
+
visibleCommands(cmd) {
|
|
3655
|
+
return cmd.commands
|
|
3656
|
+
.filter((subcommand) => {
|
|
3657
|
+
const maybeHidden = subcommand;
|
|
3658
|
+
return !maybeHidden._hidden && helpVisibleCommandNames.has(subcommand.name());
|
|
3659
|
+
})
|
|
3660
|
+
.sort((left, right) => left.name().localeCompare(right.name()));
|
|
3661
|
+
},
|
|
3662
|
+
subcommandTerm(cmd) {
|
|
3663
|
+
const visibleName = helpAliasByCommandName.get(cmd.name()) || cmd.name();
|
|
3664
|
+
const args = cmd.registeredArguments.map((arg) => formatHelpArgumentTerm(arg)).join(" ");
|
|
3665
|
+
return visibleName + (cmd.options.length ? " [options]" : "") + (args ? " " + args : "");
|
|
3666
|
+
}
|
|
3667
|
+
});
|
|
3668
|
+
program.addHelpText("after", `
|
|
3669
|
+
LLM operator tips:
|
|
3670
|
+
- Prefer non-interactive auth: set SALESPROMPTER_TOKEN (+ optional SALESPROMPTER_API_BASE_URL).
|
|
3671
|
+
- Use machine output for tools: add --json.
|
|
3672
|
+
- One-shot leads flow: leads:pipeline --icp <path> --domain <company.com> --count <n>.
|
|
3673
|
+
- Preview contact enrichment first: contacts:resolve-profiles --in <contacts.tsv> --dry-run.
|
|
3674
|
+
- For bigger runs, start with a small sample before processing the full file.
|
|
3675
|
+
`);
|
|
2757
3676
|
program
|
|
2758
3677
|
.command("auth:login")
|
|
2759
3678
|
.description("Authenticate CLI with a Salesprompter app token, or device flow if the app supports it.")
|
|
@@ -2806,6 +3725,125 @@ program
|
|
|
2806
3725
|
createdAt: verifiedSession.createdAt
|
|
2807
3726
|
});
|
|
2808
3727
|
});
|
|
3728
|
+
program
|
|
3729
|
+
.command("llm:ready")
|
|
3730
|
+
.description("Return LLM/operator readiness info with recommended next commands.")
|
|
3731
|
+
.option("--icp <path>", "Optional ICP path for lead pipeline examples", "/tmp/deel-icp.json")
|
|
3732
|
+
.option("--domain <domain>", "Optional domain for lead pipeline examples", "deel.com")
|
|
3733
|
+
.action(async (options) => {
|
|
3734
|
+
const readiness = await resolveLlmAuthReadiness();
|
|
3735
|
+
const leadPipelineCommand = [
|
|
3736
|
+
"salesprompter --json leads:pipeline",
|
|
3737
|
+
`--icp ${shellQuote(String(options.icp))}`,
|
|
3738
|
+
`--domain ${shellQuote(String(options.domain))}`,
|
|
3739
|
+
"--count 50",
|
|
3740
|
+
"--out-prefix /tmp/leads-run"
|
|
3741
|
+
].join(" ");
|
|
3742
|
+
const salesNavCountCommand = [
|
|
3743
|
+
"salesprompter --json salesnav:count",
|
|
3744
|
+
"--query-url \"https://www.linkedin.com/sales/search/people?query=...\"",
|
|
3745
|
+
"--number-of-profiles 1"
|
|
3746
|
+
].join(" ");
|
|
3747
|
+
printOutput({
|
|
3748
|
+
status: "ok",
|
|
3749
|
+
ready: readiness.ready,
|
|
3750
|
+
auth: {
|
|
3751
|
+
mode: readiness.mode,
|
|
3752
|
+
apiBaseUrl: readiness.apiBaseUrl,
|
|
3753
|
+
user: readiness.user,
|
|
3754
|
+
reason: readiness.reason
|
|
3755
|
+
},
|
|
3756
|
+
recommended: {
|
|
3757
|
+
leadPipeline: leadPipelineCommand,
|
|
3758
|
+
salesNavCount: salesNavCountCommand
|
|
3759
|
+
},
|
|
3760
|
+
tips: [
|
|
3761
|
+
"Use SALESPROMPTER_TOKEN for non-interactive runs.",
|
|
3762
|
+
"Add --json for machine-readable outputs.",
|
|
3763
|
+
"For live crawls, start with --max-slices 1."
|
|
3764
|
+
]
|
|
3765
|
+
});
|
|
3766
|
+
});
|
|
3767
|
+
program
|
|
3768
|
+
.command("contacts:find-linkedin-urls")
|
|
3769
|
+
.alias("contacts:resolve-profiles")
|
|
3770
|
+
.description("Resolve profile URLs for a pasted contacts list directly in the CLI.")
|
|
3771
|
+
.option("--in <path>", "Input TSV/CSV/JSON file path. Omit to read from stdin.")
|
|
3772
|
+
.option("--out <path>", "Optional output JSON path for the enriched rows.")
|
|
3773
|
+
.option("--org-id <id>", "Optional Sales Nav workspace org id for the lookup-first pass.")
|
|
3774
|
+
.option("--external-user-id <id>", "Deprecated compatibility option. Ignored by the direct CLI lookup.")
|
|
3775
|
+
.option("--timeout-ms <number>", "Lookup timeout in milliseconds", "30000")
|
|
3776
|
+
.option("--dry-run", "Preview the normalized payload without calling LinkedIn", false)
|
|
3777
|
+
.action(async (options) => {
|
|
3778
|
+
const timeoutMs = z.coerce.number().int().min(1000).max(300000).parse(options.timeoutMs);
|
|
3779
|
+
const inputContent = options.in ? await readFile(options.in, "utf8") : await readAllStdin();
|
|
3780
|
+
const rows = parseLinkedInUrlLookupInput(inputContent);
|
|
3781
|
+
if (rows.length === 0) {
|
|
3782
|
+
throw new Error("No contact rows found. Provide TSV/CSV/JSON input via --in or stdin.");
|
|
3783
|
+
}
|
|
3784
|
+
let sessionOrgId = "";
|
|
3785
|
+
if (!shouldBypassAuth()) {
|
|
3786
|
+
try {
|
|
3787
|
+
const session = await requireAuthSession();
|
|
3788
|
+
sessionOrgId = session.user.orgId ?? "";
|
|
3789
|
+
}
|
|
3790
|
+
catch {
|
|
3791
|
+
sessionOrgId = "";
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
const contacts = toLinkedInUrlLookupContacts(rows);
|
|
3795
|
+
if (options.dryRun) {
|
|
3796
|
+
const payload = {
|
|
3797
|
+
status: "ok",
|
|
3798
|
+
dryRun: true,
|
|
3799
|
+
orgId: String(options.orgId ?? "").trim() || null,
|
|
3800
|
+
contacts: contacts.length,
|
|
3801
|
+
sample: contacts.slice(0, 5)
|
|
3802
|
+
};
|
|
3803
|
+
if (options.out) {
|
|
3804
|
+
await writeJsonFile(options.out, payload);
|
|
3805
|
+
}
|
|
3806
|
+
printOutput(payload);
|
|
3807
|
+
return;
|
|
3808
|
+
}
|
|
3809
|
+
const enrichedRows = await resolveLinkedInUrlsFromSalesNavRows({
|
|
3810
|
+
rows,
|
|
3811
|
+
orgId: String(options.orgId ?? "").trim() || undefined
|
|
3812
|
+
});
|
|
3813
|
+
let directAttempted = false;
|
|
3814
|
+
const missingRows = enrichedRows.filter((row) => !row.found);
|
|
3815
|
+
if (missingRows.length > 0) {
|
|
3816
|
+
directAttempted = true;
|
|
3817
|
+
const directContacts = contacts.filter((contact) => missingRows.some((row) => row.contactId === contact.contact_id));
|
|
3818
|
+
const result = await invokeLinkedInUrlEnrichmentDirect({
|
|
3819
|
+
contacts: directContacts,
|
|
3820
|
+
timeoutMs
|
|
3821
|
+
});
|
|
3822
|
+
const linkedInUrlByContactId = new Map(result.contacts.map((contact) => [contact.contact_id, contact.linkedin_url]));
|
|
3823
|
+
for (const row of enrichedRows) {
|
|
3824
|
+
if (row.found)
|
|
3825
|
+
continue;
|
|
3826
|
+
const linkedinUrl = linkedInUrlByContactId.get(row.contactId) ?? null;
|
|
3827
|
+
if (linkedinUrl) {
|
|
3828
|
+
row.linkedinUrl = linkedinUrl;
|
|
3829
|
+
row.found = true;
|
|
3830
|
+
row.source = "linkedin-direct";
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
const payload = {
|
|
3835
|
+
status: "ok",
|
|
3836
|
+
orgId: String(options.orgId ?? "").trim() || null,
|
|
3837
|
+
requested: rows.length,
|
|
3838
|
+
found: enrichedRows.filter((row) => row.found).length,
|
|
3839
|
+
directAttempted,
|
|
3840
|
+
rows: enrichedRows
|
|
3841
|
+
};
|
|
3842
|
+
if (options.out) {
|
|
3843
|
+
await writeJsonFile(options.out, payload);
|
|
3844
|
+
}
|
|
3845
|
+
printOutput(payload);
|
|
3846
|
+
});
|
|
2809
3847
|
program
|
|
2810
3848
|
.command("auth:logout")
|
|
2811
3849
|
.description("Remove local CLI auth session.")
|
|
@@ -2816,7 +3854,10 @@ program
|
|
|
2816
3854
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
2817
3855
|
applyGlobalOutputOptions(actionCommand);
|
|
2818
3856
|
const commandName = actionCommand.name();
|
|
2819
|
-
if (commandName.startsWith("auth:") ||
|
|
3857
|
+
if (commandName.startsWith("auth:") ||
|
|
3858
|
+
commandName === "wizard" ||
|
|
3859
|
+
commandName === "llm:ready" ||
|
|
3860
|
+
commandName === "contacts:find-linkedin-urls") {
|
|
2820
3861
|
return;
|
|
2821
3862
|
}
|
|
2822
3863
|
const commandOptions = actionCommand.opts();
|
|
@@ -2829,6 +3870,11 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
|
2829
3870
|
if (shouldBypassAuth()) {
|
|
2830
3871
|
return;
|
|
2831
3872
|
}
|
|
3873
|
+
const envToken = resolveNonInteractiveAuthToken(process.env);
|
|
3874
|
+
if (envToken) {
|
|
3875
|
+
await loginWithToken(envToken, process.env.SALESPROMPTER_API_BASE_URL?.trim());
|
|
3876
|
+
return;
|
|
3877
|
+
}
|
|
2832
3878
|
try {
|
|
2833
3879
|
const session = await requireAuthSession();
|
|
2834
3880
|
if (session.expiresAt !== undefined && Date.now() >= Date.parse(session.expiresAt)) {
|
|
@@ -2836,7 +3882,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
|
2836
3882
|
await ensureInteractiveAuthSession(session.apiBaseUrl);
|
|
2837
3883
|
return;
|
|
2838
3884
|
}
|
|
2839
|
-
throw new Error("session expired. Run `salesprompter auth:login
|
|
3885
|
+
throw new Error("session expired. Run `salesprompter auth:login` or set SALESPROMPTER_TOKEN for non-interactive runs.");
|
|
2840
3886
|
}
|
|
2841
3887
|
}
|
|
2842
3888
|
catch (error) {
|
|
@@ -2852,7 +3898,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
|
2852
3898
|
program
|
|
2853
3899
|
.command("account:resolve")
|
|
2854
3900
|
.description("Resolve a target company into a normalized account profile.")
|
|
2855
|
-
.requiredOption("--domain <domain>", "Company domain like
|
|
3901
|
+
.requiredOption("--domain <domain>", "Company domain like company.com")
|
|
2856
3902
|
.option("--company-name <name>", "Optional company name override")
|
|
2857
3903
|
.option("--icp <path>", "Optional path to ICP JSON for industry/region/title hints")
|
|
2858
3904
|
.requiredOption("--out <path>", "Output file path")
|
|
@@ -2911,7 +3957,7 @@ program
|
|
|
2911
3957
|
program
|
|
2912
3958
|
.command("icp:vendor")
|
|
2913
3959
|
.description("Create a vendor-specific ICP template.")
|
|
2914
|
-
.requiredOption("--vendor <vendor>", "Vendor template name
|
|
3960
|
+
.requiredOption("--vendor <vendor>", "Vendor template name")
|
|
2915
3961
|
.option("--market <market>", "global|europe|dach", "dach")
|
|
2916
3962
|
.option("--out <path>", "Optional output file path")
|
|
2917
3963
|
.action(async (options) => {
|
|
@@ -2926,7 +3972,7 @@ program
|
|
|
2926
3972
|
program
|
|
2927
3973
|
.command("icp:from-historical-queries:bq")
|
|
2928
3974
|
.description("Build a vendor ICP from historical BigQuery query patterns.")
|
|
2929
|
-
.requiredOption("--vendor <vendor>", "Vendor template name
|
|
3975
|
+
.requiredOption("--vendor <vendor>", "Vendor template name")
|
|
2930
3976
|
.option("--market <market>", "global|europe|dach", "dach")
|
|
2931
3977
|
.option("--tables <items>", "Comma-separated BigQuery tables with a query column", "leadLists_raw,leadLists_unique,linkedinSearchExport_people_unique,salesNavigatorSearchExport_companies_unique,snse_containers_input")
|
|
2932
3978
|
.option("--search-kind <kind>", "all|people|sales-people|sales-company", "sales-people")
|
|
@@ -2966,7 +4012,7 @@ program
|
|
|
2966
4012
|
.description("Generate leads for a target account or from fallback seeds.")
|
|
2967
4013
|
.requiredOption("--icp <path>", "Path to ICP JSON")
|
|
2968
4014
|
.option("--count <number>", "Number of leads to generate", "10")
|
|
2969
|
-
.option("--domain <domain>", "Target a specific company domain like
|
|
4015
|
+
.option("--domain <domain>", "Target a specific company domain like company.com")
|
|
2970
4016
|
.option("--company-domain <domain>", "Deprecated alias for --domain")
|
|
2971
4017
|
.option("--company-name <name>", "Optional company name override for a targeted domain")
|
|
2972
4018
|
.requiredOption("--out <path>", "Output file path")
|
|
@@ -3015,6 +4061,51 @@ program
|
|
|
3015
4061
|
await writeJsonFile(options.out, scored);
|
|
3016
4062
|
printOutput({ status: "ok", scored: scored.length, out: options.out });
|
|
3017
4063
|
});
|
|
4064
|
+
program
|
|
4065
|
+
.command("leads:pipeline")
|
|
4066
|
+
.description("Run one-shot lead generation pipeline: generate -> enrich -> score.")
|
|
4067
|
+
.requiredOption("--icp <path>", "Path to ICP JSON")
|
|
4068
|
+
.option("--count <number>", "Number of leads to generate", "10")
|
|
4069
|
+
.option("--domain <domain>", "Target a specific company domain like company.com")
|
|
4070
|
+
.option("--company-domain <domain>", "Deprecated alias for --domain")
|
|
4071
|
+
.option("--company-name <name>", "Optional company name override for a targeted domain")
|
|
4072
|
+
.option("--out-prefix <path>", "Output path prefix (writes <prefix>-leads.json, <prefix>-enriched.json, <prefix>-scored.json)", "./data/leads-pipeline")
|
|
4073
|
+
.action(async (options) => {
|
|
4074
|
+
const icp = await readJsonFile(options.icp, IcpSchema);
|
|
4075
|
+
const count = z.coerce.number().int().min(1).max(1000).parse(options.count);
|
|
4076
|
+
const domain = options.domain ?? options.companyDomain;
|
|
4077
|
+
const target = {
|
|
4078
|
+
companyDomain: domain,
|
|
4079
|
+
companyName: options.companyName
|
|
4080
|
+
};
|
|
4081
|
+
const outPrefix = String(options.outPrefix);
|
|
4082
|
+
const leadsOut = `${outPrefix}-leads.json`;
|
|
4083
|
+
const enrichedOut = `${outPrefix}-enriched.json`;
|
|
4084
|
+
const scoredOut = `${outPrefix}-scored.json`;
|
|
4085
|
+
const generated = await leadProvider.generateLeads(icp, count, target);
|
|
4086
|
+
await writeJsonFile(leadsOut, generated.leads);
|
|
4087
|
+
const enriched = await enrichmentProvider.enrichLeads(generated.leads);
|
|
4088
|
+
await writeJsonFile(enrichedOut, enriched);
|
|
4089
|
+
const scored = await scoringProvider.scoreLeads(icp, enriched);
|
|
4090
|
+
await writeJsonFile(scoredOut, scored);
|
|
4091
|
+
printOutput({
|
|
4092
|
+
status: "ok",
|
|
4093
|
+
provider: generated.provider,
|
|
4094
|
+
mode: generated.mode,
|
|
4095
|
+
account: generated.account,
|
|
4096
|
+
warnings: generated.warnings,
|
|
4097
|
+
counts: {
|
|
4098
|
+
generated: generated.leads.length,
|
|
4099
|
+
enriched: enriched.length,
|
|
4100
|
+
scored: scored.length
|
|
4101
|
+
},
|
|
4102
|
+
outputs: {
|
|
4103
|
+
leads: leadsOut,
|
|
4104
|
+
enriched: enrichedOut,
|
|
4105
|
+
scored: scoredOut
|
|
4106
|
+
}
|
|
4107
|
+
});
|
|
4108
|
+
});
|
|
3018
4109
|
program
|
|
3019
4110
|
.command("sync:crm")
|
|
3020
4111
|
.description("Dry-run sync scored leads into a CRM target.")
|
|
@@ -3044,9 +4135,56 @@ program
|
|
|
3044
4135
|
});
|
|
3045
4136
|
printOutput({ status: "ok", ...result });
|
|
3046
4137
|
});
|
|
4138
|
+
program
|
|
4139
|
+
.command("linkedin-companies:backfill")
|
|
4140
|
+
.alias("companies:enrich")
|
|
4141
|
+
.description("Backfill missing or unavailable company profiles for the current workspace.")
|
|
4142
|
+
.requiredOption("--client-id <number>", "Legacy BigQuery clientId to backfill")
|
|
4143
|
+
.option("--limit <number>", "Maximum companies to scrape in one run", "25")
|
|
4144
|
+
.option("--concurrency <number>", "How many LinkedIn company pages to scrape in parallel", "4")
|
|
4145
|
+
.option("--dry-run", "Preview the scrape result and generated MERGE SQL without writing to BigQuery", false)
|
|
4146
|
+
.action(async (options) => {
|
|
4147
|
+
const clientId = z.coerce.number().int().positive().parse(options.clientId);
|
|
4148
|
+
const limit = z.coerce.number().int().min(1).max(500).parse(options.limit);
|
|
4149
|
+
const concurrency = z.coerce.number().int().min(1).max(20).parse(options.concurrency);
|
|
4150
|
+
if (!options.dryRun && !shouldBypassAuth()) {
|
|
4151
|
+
const session = await requireAuthSession();
|
|
4152
|
+
const drained = await drainLinkedInCompanyBackfill(session, {
|
|
4153
|
+
clientId,
|
|
4154
|
+
limit
|
|
4155
|
+
});
|
|
4156
|
+
printOutput({
|
|
4157
|
+
status: "ok",
|
|
4158
|
+
dryRun: false,
|
|
4159
|
+
clientId,
|
|
4160
|
+
completed: drained.completed,
|
|
4161
|
+
batches: drained.batches,
|
|
4162
|
+
started: drained.startedCompanies,
|
|
4163
|
+
remaining: drained.remaining
|
|
4164
|
+
});
|
|
4165
|
+
return;
|
|
4166
|
+
}
|
|
4167
|
+
const result = await backfillLinkedInCompanies({
|
|
4168
|
+
clientId,
|
|
4169
|
+
limit,
|
|
4170
|
+
concurrency,
|
|
4171
|
+
dryRun: true
|
|
4172
|
+
});
|
|
4173
|
+
printOutput({
|
|
4174
|
+
status: "ok",
|
|
4175
|
+
dryRun: true,
|
|
4176
|
+
clientId,
|
|
4177
|
+
discovered: result.candidates.length,
|
|
4178
|
+
unavailable: result.results.filter((row) => row.unavailable).length,
|
|
4179
|
+
stored: 0,
|
|
4180
|
+
results: result.results,
|
|
4181
|
+
mergeSql: result.mergeSql
|
|
4182
|
+
});
|
|
4183
|
+
});
|
|
3047
4184
|
program
|
|
3048
4185
|
.command("linkedin-products:scrape")
|
|
3049
|
-
.
|
|
4186
|
+
.alias("market:scrape")
|
|
4187
|
+
.description("Turn a company or product input into a reusable market catalog and upload it to Salesprompter.")
|
|
3050
4188
|
.requiredOption("--input <value>", "Company domain, LinkedIn company URL, LinkedIn product URL, LinkedIn category URL, or LinkedIn product search URL")
|
|
3051
4189
|
.option("--max-pages <number>", "Maximum LinkedIn category pages to fetch", "25")
|
|
3052
4190
|
.option("--limit <number>", "Optional cap on the number of products to keep")
|
|
@@ -3093,7 +4231,8 @@ program
|
|
|
3093
4231
|
});
|
|
3094
4232
|
program
|
|
3095
4233
|
.command("salesnav:from-product-category")
|
|
3096
|
-
.
|
|
4234
|
+
.alias("leads:discover")
|
|
4235
|
+
.description("Start from a market or product input, derive the right buyer searches, and discover leads.")
|
|
3097
4236
|
.requiredOption("--input <value>", "Company domain, LinkedIn company URL, LinkedIn product URL, LinkedIn category URL, or LinkedIn product search URL")
|
|
3098
4237
|
.option("--max-pages <number>", "Maximum LinkedIn category pages to fetch", "25")
|
|
3099
4238
|
.option("--product-limit <number>", "Optional cap on the number of LinkedIn products to inspect")
|
|
@@ -3306,16 +4445,31 @@ program
|
|
|
3306
4445
|
});
|
|
3307
4446
|
program
|
|
3308
4447
|
.command("salesnav:deel-locale-export")
|
|
4448
|
+
.alias("salesnav:vendor-locale-export")
|
|
3309
4449
|
.description("Export the Supabase Sales Navigator Deel corpus into German-vs-English outreach backlog files.")
|
|
3310
4450
|
.option("--org-id <id>", "Workspace org id. Defaults to the active CLI org.")
|
|
3311
4451
|
.option("--limit <number>", "Maximum number of Supabase rows to process", "250000")
|
|
3312
4452
|
.option("--page-size <number>", "Supabase page size per request", "1000")
|
|
3313
4453
|
.option("--title-filter <mode>", "deel-hr|all", "deel-hr")
|
|
4454
|
+
.option("--min-email-score <number>", "Minimum email score when enriching leadPool rows", "70")
|
|
4455
|
+
.option("--enrich", "Join the kept Sales Navigator rows to leadPool_new emails", false)
|
|
4456
|
+
.option("--campaign-id <id>", "Fallback Instantly campaign id for both locales")
|
|
4457
|
+
.option("--campaign-id-de <id>", "Instantly campaign id for German leads")
|
|
4458
|
+
.option("--campaign-id-en <id>", "Instantly campaign id for English leads")
|
|
4459
|
+
.option("--apply", "Create leads in Instantly instead of export-only mode", false)
|
|
4460
|
+
.option("--allow-duplicates", "Do not dedupe leads that already exist in the campaign", false)
|
|
3314
4461
|
.requiredOption("--out-dir <path>", "Output directory for summary and locale CSV files")
|
|
3315
4462
|
.action(async (options) => {
|
|
3316
4463
|
const limit = z.coerce.number().int().min(1).max(500000).parse(options.limit);
|
|
3317
4464
|
const pageSize = z.coerce.number().int().min(1).max(1000).parse(options.pageSize);
|
|
3318
4465
|
const titleFilter = z.enum(["deel-hr", "all"]).parse(options.titleFilter);
|
|
4466
|
+
const minEmailScore = z.coerce.number().int().min(0).max(100).parse(options.minEmailScore);
|
|
4467
|
+
const enrich = Boolean(options.enrich);
|
|
4468
|
+
const fallbackCampaignId = options.campaignId?.trim();
|
|
4469
|
+
const campaignIdDe = options.campaignIdDe?.trim() ?? fallbackCampaignId;
|
|
4470
|
+
const campaignIdEn = options.campaignIdEn?.trim() ?? fallbackCampaignId;
|
|
4471
|
+
const applySync = Boolean(options.apply);
|
|
4472
|
+
const allowDuplicates = Boolean(options.allowDuplicates);
|
|
3319
4473
|
let sessionOrgId = null;
|
|
3320
4474
|
if (!shouldBypassAuth()) {
|
|
3321
4475
|
const session = await requireAuthSession();
|
|
@@ -3374,6 +4528,7 @@ program
|
|
|
3374
4528
|
de: [],
|
|
3375
4529
|
en: []
|
|
3376
4530
|
};
|
|
4531
|
+
const keptRows = [];
|
|
3377
4532
|
const selectFields = [
|
|
3378
4533
|
"id",
|
|
3379
4534
|
"org_id",
|
|
@@ -3430,6 +4585,7 @@ program
|
|
|
3430
4585
|
const preparedRows = relevantRows.map((row) => normalizeDeelSalesNavRow(row));
|
|
3431
4586
|
const deRows = preparedRows.filter((row) => row.language === "de");
|
|
3432
4587
|
const enRows = preparedRows.filter((row) => row.language === "en");
|
|
4588
|
+
keptRows.push(...preparedRows);
|
|
3433
4589
|
if (deRows.length > 0) {
|
|
3434
4590
|
await appendFile(deCsvPath, `${buildDeelSalesNavCsvLines(deRows)}\n`, "utf8");
|
|
3435
4591
|
}
|
|
@@ -3492,6 +4648,100 @@ program
|
|
|
3492
4648
|
}
|
|
3493
4649
|
const keptTotal = localeCounts.de + localeCounts.en;
|
|
3494
4650
|
const percentage = (count, base = keptTotal) => base > 0 ? Number(((count / base) * 100).toFixed(2)) : 0;
|
|
4651
|
+
let enrichmentSummary = null;
|
|
4652
|
+
if (enrich) {
|
|
4653
|
+
const localeByContactId = new Map();
|
|
4654
|
+
for (const row of keptRows) {
|
|
4655
|
+
const contactId = extractSalesNavContactId(row.salesNavProfileUrl);
|
|
4656
|
+
if (contactId) {
|
|
4657
|
+
localeByContactId.set(contactId, row.language);
|
|
4658
|
+
}
|
|
4659
|
+
}
|
|
4660
|
+
const contactIds = Array.from(localeByContactId.keys());
|
|
4661
|
+
if (contactIds.length === 0) {
|
|
4662
|
+
enrichmentSummary = {
|
|
4663
|
+
contactIds: 0,
|
|
4664
|
+
enrichedRows: 0,
|
|
4665
|
+
missingContactIds: [],
|
|
4666
|
+
files: {
|
|
4667
|
+
all: "",
|
|
4668
|
+
de: "",
|
|
4669
|
+
en: ""
|
|
4670
|
+
},
|
|
4671
|
+
syncResults: []
|
|
4672
|
+
};
|
|
4673
|
+
}
|
|
4674
|
+
else {
|
|
4675
|
+
const enrichedRows = [];
|
|
4676
|
+
for (const chunk of chunkArray(contactIds, 400)) {
|
|
4677
|
+
const sql = buildDeelLeadPoolContactSql({ contactIds: chunk, minEmailScore });
|
|
4678
|
+
const rows = await runBigQueryRows(sql, { maxRows: chunk.length * 2 });
|
|
4679
|
+
enrichedRows.push(...rows);
|
|
4680
|
+
}
|
|
4681
|
+
const normalizedRows = normalizeDeelOutreachRows(enrichedRows);
|
|
4682
|
+
const foundContactIds = new Set();
|
|
4683
|
+
for (const row of normalizedRows) {
|
|
4684
|
+
if (row.contactId) {
|
|
4685
|
+
foundContactIds.add(row.contactId);
|
|
4686
|
+
const overrideLanguage = localeByContactId.get(row.contactId);
|
|
4687
|
+
if (overrideLanguage) {
|
|
4688
|
+
row.language = overrideLanguage;
|
|
4689
|
+
row.marketSegment = overrideLanguage === "de" ? "dach" : "non-dach";
|
|
4690
|
+
}
|
|
4691
|
+
}
|
|
4692
|
+
}
|
|
4693
|
+
const missingContactIds = contactIds.filter((id) => !foundContactIds.has(id));
|
|
4694
|
+
const pack = buildDeelOutreachPack("global", normalizedRows);
|
|
4695
|
+
const enrichedAllPath = path.join(options.outDir, `${baseSlug}-enriched-all.json`);
|
|
4696
|
+
const enrichedDePath = path.join(options.outDir, `${baseSlug}-enriched-de.json`);
|
|
4697
|
+
const enrichedEnPath = path.join(options.outDir, `${baseSlug}-enriched-en.json`);
|
|
4698
|
+
await writeJsonFile(enrichedAllPath, normalizedRows);
|
|
4699
|
+
await writeJsonFile(enrichedDePath, pack.locales.de);
|
|
4700
|
+
await writeJsonFile(enrichedEnPath, pack.locales.en);
|
|
4701
|
+
const syncResults = [];
|
|
4702
|
+
const routes = [
|
|
4703
|
+
{
|
|
4704
|
+
locale: "de",
|
|
4705
|
+
campaignId: campaignIdDe,
|
|
4706
|
+
leads: pack.locales.de
|
|
4707
|
+
},
|
|
4708
|
+
{
|
|
4709
|
+
locale: "en",
|
|
4710
|
+
campaignId: campaignIdEn,
|
|
4711
|
+
leads: pack.locales.en
|
|
4712
|
+
}
|
|
4713
|
+
];
|
|
4714
|
+
for (const route of routes) {
|
|
4715
|
+
if (!route.campaignId || route.leads.length === 0) {
|
|
4716
|
+
continue;
|
|
4717
|
+
}
|
|
4718
|
+
const result = await syncProvider.sync("instantly", route.leads, {
|
|
4719
|
+
apply: applySync,
|
|
4720
|
+
instantlyCampaignId: route.campaignId,
|
|
4721
|
+
allowDuplicates
|
|
4722
|
+
});
|
|
4723
|
+
syncResults.push({
|
|
4724
|
+
locale: route.locale,
|
|
4725
|
+
campaignId: route.campaignId,
|
|
4726
|
+
synced: result.synced,
|
|
4727
|
+
skipped: result.skipped ?? 0,
|
|
4728
|
+
dryRun: result.dryRun,
|
|
4729
|
+
provider: result.provider ?? "instantly"
|
|
4730
|
+
});
|
|
4731
|
+
}
|
|
4732
|
+
enrichmentSummary = {
|
|
4733
|
+
contactIds: contactIds.length,
|
|
4734
|
+
enrichedRows: normalizedRows.length,
|
|
4735
|
+
missingContactIds,
|
|
4736
|
+
files: {
|
|
4737
|
+
all: enrichedAllPath,
|
|
4738
|
+
de: enrichedDePath,
|
|
4739
|
+
en: enrichedEnPath
|
|
4740
|
+
},
|
|
4741
|
+
syncResults
|
|
4742
|
+
};
|
|
4743
|
+
}
|
|
4744
|
+
}
|
|
3495
4745
|
const payload = {
|
|
3496
4746
|
status: "ok",
|
|
3497
4747
|
vendor: "deel",
|
|
@@ -3549,7 +4799,8 @@ program
|
|
|
3549
4799
|
enCsv: enCsvPath,
|
|
3550
4800
|
summary: summaryPath,
|
|
3551
4801
|
samples: samplesPath
|
|
3552
|
-
}
|
|
4802
|
+
},
|
|
4803
|
+
enrichment: enrichmentSummary
|
|
3553
4804
|
};
|
|
3554
4805
|
await writeJsonFile(summaryPath, payload);
|
|
3555
4806
|
await writeJsonFile(samplesPath, samples);
|
|
@@ -3557,7 +4808,8 @@ program
|
|
|
3557
4808
|
});
|
|
3558
4809
|
program
|
|
3559
4810
|
.command("salesnav:crawl")
|
|
3560
|
-
.
|
|
4811
|
+
.alias("search:run")
|
|
4812
|
+
.description("Run a saved people search, split broad result sets when needed, and store the finished output.")
|
|
3561
4813
|
.option("--query-url <url>", "Base LinkedIn Sales Navigator people search URL")
|
|
3562
4814
|
.option("--job-id <id>", "Resume an existing crawl job by id")
|
|
3563
4815
|
.option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
|
|
@@ -3590,6 +4842,7 @@ program
|
|
|
3590
4842
|
const idlePollSeconds = z.coerce.number().int().min(0).max(300).parse(options.idlePollSeconds);
|
|
3591
4843
|
const idleMaxPolls = z.coerce.number().int().min(0).max(10000).parse(options.idleMaxPolls);
|
|
3592
4844
|
const parallelExports = z.coerce.number().int().min(1).max(10).parse(options.parallelExports);
|
|
4845
|
+
const phantomLaneLimit = resolveSalesNavigatorPhantomLaneLimit(process.env);
|
|
3593
4846
|
const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
|
|
3594
4847
|
const logger = await createWorkflowLogger({
|
|
3595
4848
|
logPath: options.logPath ?? buildSalesNavigatorCrawlLogPath(jobId ?? queryUrl ?? "salesnav-crawl")
|
|
@@ -3609,6 +4862,7 @@ program
|
|
|
3609
4862
|
idlePollSeconds,
|
|
3610
4863
|
idleMaxPolls,
|
|
3611
4864
|
parallelExports,
|
|
4865
|
+
phantomLaneLimit,
|
|
3612
4866
|
dryRun: effectiveDryRun
|
|
3613
4867
|
});
|
|
3614
4868
|
if (effectiveDryRun) {
|
|
@@ -3672,6 +4926,13 @@ program
|
|
|
3672
4926
|
throw new Error("Provide exactly one of --query-url or --job-id.");
|
|
3673
4927
|
}
|
|
3674
4928
|
let session = await requireAuthSession();
|
|
4929
|
+
const sessionOrgId = resolveSessionOrgId(session);
|
|
4930
|
+
if (sessionOrgId) {
|
|
4931
|
+
const eventStore = await createSalesNavigatorCrawlEventStore({
|
|
4932
|
+
orgId: sessionOrgId
|
|
4933
|
+
});
|
|
4934
|
+
logger.setEventStore(eventStore);
|
|
4935
|
+
}
|
|
3675
4936
|
let createResult = null;
|
|
3676
4937
|
let resolvedJobId = jobId ?? null;
|
|
3677
4938
|
if (queryUrl) {
|
|
@@ -3799,23 +5060,42 @@ program
|
|
|
3799
5060
|
});
|
|
3800
5061
|
program
|
|
3801
5062
|
.command("salesnav:crawl:status")
|
|
3802
|
-
.
|
|
5063
|
+
.alias("search:status")
|
|
5064
|
+
.description("Return the current status of a background people-search job.")
|
|
3803
5065
|
.requiredOption("--job-id <id>", "Sales Navigator crawl job id")
|
|
5066
|
+
.option("--events-limit <number>", "How many recent persisted crawl events to include", "25")
|
|
3804
5067
|
.action(async (options) => {
|
|
3805
5068
|
const jobId = z.string().uuid().parse(options.jobId);
|
|
5069
|
+
const eventsLimit = z.coerce.number().int().min(0).max(200).parse(options.eventsLimit);
|
|
3806
5070
|
let session = await requireAuthSession();
|
|
3807
5071
|
const status = await getSalesNavigatorCrawlStatus(session, jobId);
|
|
3808
5072
|
session = status.session;
|
|
3809
|
-
|
|
5073
|
+
const sessionOrgId = resolveSessionOrgId(session);
|
|
5074
|
+
const eventStore = sessionOrgId
|
|
5075
|
+
? await createSalesNavigatorCrawlEventStore({
|
|
5076
|
+
orgId: sessionOrgId
|
|
5077
|
+
})
|
|
5078
|
+
: null;
|
|
5079
|
+
let recentEvents = [];
|
|
5080
|
+
if (eventsLimit > 0 && eventStore) {
|
|
5081
|
+
try {
|
|
5082
|
+
recentEvents = await eventStore.listRecentForJob(jobId, eventsLimit);
|
|
5083
|
+
}
|
|
5084
|
+
catch {
|
|
5085
|
+
recentEvents = [];
|
|
5086
|
+
}
|
|
5087
|
+
}
|
|
3810
5088
|
printOutput({
|
|
3811
5089
|
status: "ok",
|
|
3812
5090
|
jobId,
|
|
3813
|
-
job: status.value.job
|
|
5091
|
+
job: status.value.job,
|
|
5092
|
+
recentEvents
|
|
3814
5093
|
});
|
|
3815
5094
|
});
|
|
3816
5095
|
program
|
|
3817
5096
|
.command("salesnav:export")
|
|
3818
|
-
.
|
|
5097
|
+
.alias("search:export")
|
|
5098
|
+
.description("Apply the default people-search filters to one or more search URLs, then export and store the results.")
|
|
3819
5099
|
.requiredOption("--query-url <url>", "Base LinkedIn Sales Navigator people search URL", collectStringOptionValue, [])
|
|
3820
5100
|
.option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
|
|
3821
5101
|
.option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
|
|
@@ -3876,6 +5156,71 @@ program
|
|
|
3876
5156
|
}
|
|
3877
5157
|
printOutput(payload);
|
|
3878
5158
|
});
|
|
5159
|
+
program
|
|
5160
|
+
.command("salesnav:count")
|
|
5161
|
+
.alias("search:count")
|
|
5162
|
+
.description("Estimate how many results are available for a people-search URL using a minimal probe.")
|
|
5163
|
+
.requiredOption("--query-url <url>", "LinkedIn Sales Navigator people search URL")
|
|
5164
|
+
.option("--max-results-per-search <number>", "Maximum results allowed for the probe", "2500")
|
|
5165
|
+
.option("--number-of-profiles <number>", "Profiles to scrape for the probe run", "1")
|
|
5166
|
+
.option("--slice-preset <name>", "Slice preset label stored with the probe run", "count-probe")
|
|
5167
|
+
.option("--out <path>", "Optional local JSON output path")
|
|
5168
|
+
.action(async (options) => {
|
|
5169
|
+
const queryUrl = z.string().url().parse(options.queryUrl);
|
|
5170
|
+
const maxResultsPerSearch = z.coerce.number().int().min(1).max(2500).parse(options.maxResultsPerSearch);
|
|
5171
|
+
const numberOfProfiles = z.coerce.number().int().min(1).max(25).parse(options.numberOfProfiles);
|
|
5172
|
+
const session = await requireAuthSession();
|
|
5173
|
+
const payloadBase = {
|
|
5174
|
+
sourceQueryUrl: queryUrl,
|
|
5175
|
+
slicedQueryUrl: queryUrl,
|
|
5176
|
+
appliedFilters: [],
|
|
5177
|
+
maxResultsPerSearch,
|
|
5178
|
+
numberOfProfiles,
|
|
5179
|
+
slicePreset: options.slicePreset,
|
|
5180
|
+
rawPayload: {
|
|
5181
|
+
workflow: "salesnav:count",
|
|
5182
|
+
sourceQueryUrl: queryUrl,
|
|
5183
|
+
slicedQueryUrl: queryUrl,
|
|
5184
|
+
probeProfiles: numberOfProfiles
|
|
5185
|
+
}
|
|
5186
|
+
};
|
|
5187
|
+
try {
|
|
5188
|
+
const result = await runSalesNavigatorExport(session, payloadBase);
|
|
5189
|
+
const payload = {
|
|
5190
|
+
status: "ok",
|
|
5191
|
+
queryUrl,
|
|
5192
|
+
estimatedTotalResults: result.totalResults ?? null,
|
|
5193
|
+
probeProfiles: numberOfProfiles,
|
|
5194
|
+
runId: result.runId,
|
|
5195
|
+
agentId: result.agentId,
|
|
5196
|
+
containerId: result.containerId,
|
|
5197
|
+
resultJsonUrl: result.resultJsonUrl ?? null,
|
|
5198
|
+
resultCsvUrl: result.resultCsvUrl ?? null
|
|
5199
|
+
};
|
|
5200
|
+
if (options.out) {
|
|
5201
|
+
await writeJsonFile(options.out, payload);
|
|
5202
|
+
}
|
|
5203
|
+
printOutput(payload);
|
|
5204
|
+
return;
|
|
5205
|
+
}
|
|
5206
|
+
catch (error) {
|
|
5207
|
+
if (error instanceof SalesNavigatorSliceTooBroadError) {
|
|
5208
|
+
const payload = {
|
|
5209
|
+
status: "ok",
|
|
5210
|
+
queryUrl,
|
|
5211
|
+
estimatedTotalResults: error.totalResults ?? null,
|
|
5212
|
+
tooBroad: true,
|
|
5213
|
+
probeProfiles: numberOfProfiles
|
|
5214
|
+
};
|
|
5215
|
+
if (options.out) {
|
|
5216
|
+
await writeJsonFile(options.out, payload);
|
|
5217
|
+
}
|
|
5218
|
+
printOutput(payload);
|
|
5219
|
+
return;
|
|
5220
|
+
}
|
|
5221
|
+
throw error;
|
|
5222
|
+
}
|
|
5223
|
+
});
|
|
3879
5224
|
program
|
|
3880
5225
|
.command("leads:lookup:bq")
|
|
3881
5226
|
.description("Build a BigQuery lead lookup from an ICP and optionally execute it with the bq CLI.")
|
|
@@ -3980,7 +5325,7 @@ program
|
|
|
3980
5325
|
program
|
|
3981
5326
|
.command("leadlists:direct-export:bq")
|
|
3982
5327
|
.description("Export upstream leads directly from leadLists -> linkedin_contacts -> linkedin_companies and segment them.")
|
|
3983
|
-
.requiredOption("--vendor <vendor>", "Vendor template name
|
|
5328
|
+
.requiredOption("--vendor <vendor>", "Vendor template name")
|
|
3984
5329
|
.option("--market <market>", "global|europe|dach", "dach")
|
|
3985
5330
|
.option("--limit <number>", "Max rows to export", "20000")
|
|
3986
5331
|
.requiredOption("--out-dir <path>", "Output directory for raw and segmented files")
|
|
@@ -4026,6 +5371,7 @@ program
|
|
|
4026
5371
|
});
|
|
4027
5372
|
program
|
|
4028
5373
|
.command("leadlists:deel-outreach:bq")
|
|
5374
|
+
.alias("leadlists:vendor-outreach:bq")
|
|
4029
5375
|
.description("Build Instantly-ready Deel outreach batches from leadPool_new with lead-list provenance, split into German vs English.")
|
|
4030
5376
|
.option("--market <market>", "global|europe|dach", "global")
|
|
4031
5377
|
.option("--limit <number>", "Max rows to export", "200000")
|
|
@@ -4115,7 +5461,7 @@ program
|
|
|
4115
5461
|
program
|
|
4116
5462
|
.command("leadlists:funnel:bq")
|
|
4117
5463
|
.description("Build an upstream lead-list funnel report for a vendor/market.")
|
|
4118
|
-
.requiredOption("--vendor <vendor>", "Vendor template name
|
|
5464
|
+
.requiredOption("--vendor <vendor>", "Vendor template name")
|
|
4119
5465
|
.option("--market <market>", "global|europe|dach", "dach")
|
|
4120
5466
|
.requiredOption("--out <path>", "Output report path")
|
|
4121
5467
|
.action(async (options) => {
|