salesprompter-cli 0.1.22 → 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 +11 -8
- package/dist/auth.js +2 -2
- package/dist/cli.js +1750 -25
- package/dist/deel-outreach.js +454 -0
- package/dist/deel-salesnav.js +368 -0
- package/dist/direct-path.js +6 -5
- package/dist/domain.js +3 -0
- package/dist/hunter-emails.js +291 -0
- package/dist/instantly.js +2 -1
- package/dist/leadlists-funnel.js +30 -13
- 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 +10 -16
package/dist/cli.js
CHANGED
|
@@ -14,11 +14,14 @@ import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWi
|
|
|
14
14
|
import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
|
|
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
|
+
import { buildDeelSalesNavCsvHeader, buildDeelSalesNavCsvLines, isDeelRelevantSalesNavTitle, normalizeDeelSalesNavRow } from "./deel-salesnav.js";
|
|
18
|
+
import { buildDeelLeadPoolContactSql, buildDeelOutreachExportSql, buildDeelOutreachPack, normalizeDeelOutreachRows } from "./deel-outreach.js";
|
|
17
19
|
import { buildDirectPathLeadExportSql, normalizeDirectPathRows, segmentDirectPathRows } from "./direct-path.js";
|
|
18
20
|
import { AccountLeadProvider, DryRunSyncProvider, HeuristicCompanyProvider, HeuristicEnrichmentProvider, HeuristicPeopleSearchProvider, HeuristicScoringProvider, RoutedSyncProvider } from "./engine.js";
|
|
19
21
|
import { analyzeHistoricalQueries } from "./historical-queries.js";
|
|
20
22
|
import { buildHistoricalVendorIcp, buildVendorIcp } from "./icp-templates.js";
|
|
21
23
|
import { InstantlySyncProvider } from "./instantly.js";
|
|
24
|
+
import { backfillLinkedInCompanies } from "./linkedin-companies.js";
|
|
22
25
|
import { crawlLinkedInProductCategory } from "./linkedin-products.js";
|
|
23
26
|
import { claimValidatedSalesNavigatorSessionCookieForCli } from "./linkedin-session.js";
|
|
24
27
|
import { buildLeadlistsFunnelQueries } from "./leadlists-funnel.js";
|
|
@@ -54,6 +57,27 @@ const LinkedInProductIngestResponseSchema = z.object({
|
|
|
54
57
|
upserted: z.number().int().nonnegative(),
|
|
55
58
|
totalInCatalog: z.number().int().nonnegative().optional()
|
|
56
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
|
+
});
|
|
57
81
|
const SalesNavigatorLaunchDiagnosticsSchema = z.object({
|
|
58
82
|
orderedCandidateAgentIds: z.array(z.string().min(1)),
|
|
59
83
|
runningAgentIds: z.array(z.string().min(1)),
|
|
@@ -212,6 +236,40 @@ const SalesNavigatorCrawlReportResponseSchema = z.object({
|
|
|
212
236
|
status: z.literal("ok"),
|
|
213
237
|
job: SalesNavigatorCrawlJobSummarySchema
|
|
214
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
|
+
]);
|
|
215
273
|
function printOutput(value) {
|
|
216
274
|
if (runtimeOutputOptions.quiet) {
|
|
217
275
|
return;
|
|
@@ -225,6 +283,13 @@ function writeProgress(message) {
|
|
|
225
283
|
}
|
|
226
284
|
process.stderr.write(`${message}\n`);
|
|
227
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
|
+
}
|
|
228
293
|
function applyGlobalOutputOptions(actionCommand) {
|
|
229
294
|
const globalOptions = actionCommand.optsWithGlobals();
|
|
230
295
|
runtimeOutputOptions.json = Boolean(globalOptions.json);
|
|
@@ -329,6 +394,27 @@ function canPromptForInteractiveLogin() {
|
|
|
329
394
|
}
|
|
330
395
|
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
331
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
|
+
}
|
|
332
418
|
async function ensureInteractiveAuthSession(apiUrl) {
|
|
333
419
|
if (!canPromptForInteractiveLogin()) {
|
|
334
420
|
return;
|
|
@@ -345,6 +431,523 @@ function shellQuote(value) {
|
|
|
345
431
|
}
|
|
346
432
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
347
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
|
+
}
|
|
348
951
|
function buildCommandLine(args) {
|
|
349
952
|
return args.map((arg) => shellQuote(arg)).join(" ");
|
|
350
953
|
}
|
|
@@ -359,6 +962,24 @@ function slugify(value) {
|
|
|
359
962
|
.replace(/^-+|-+$/g, "")
|
|
360
963
|
.replace(/-{2,}/g, "-");
|
|
361
964
|
}
|
|
965
|
+
function resolveSalesNavigatorSupabaseConfig(env = process.env) {
|
|
966
|
+
const supabaseUrl = env.SALESPROMPTER_SUPABASE_URL?.trim() || env.NEXT_PUBLIC_SUPABASE_URL?.trim() || "";
|
|
967
|
+
const supabaseServiceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY?.trim() || "";
|
|
968
|
+
const missing = [];
|
|
969
|
+
if (supabaseUrl.length === 0) {
|
|
970
|
+
missing.push("SALESPROMPTER_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL");
|
|
971
|
+
}
|
|
972
|
+
if (supabaseServiceRoleKey.length === 0) {
|
|
973
|
+
missing.push("SUPABASE_SERVICE_ROLE_KEY");
|
|
974
|
+
}
|
|
975
|
+
if (missing.length > 0) {
|
|
976
|
+
throw new Error(`Missing required environment variables for Sales Navigator Supabase export: ${missing.join(", ")}`);
|
|
977
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
supabaseUrl,
|
|
980
|
+
supabaseServiceRoleKey
|
|
981
|
+
};
|
|
982
|
+
}
|
|
362
983
|
function normalizeDomainInput(value) {
|
|
363
984
|
return value
|
|
364
985
|
.trim()
|
|
@@ -441,6 +1062,26 @@ function parseCompanyReference(value) {
|
|
|
441
1062
|
})
|
|
442
1063
|
};
|
|
443
1064
|
}
|
|
1065
|
+
function chunkArray(values, size) {
|
|
1066
|
+
const chunks = [];
|
|
1067
|
+
for (let index = 0; index < values.length; index += size) {
|
|
1068
|
+
chunks.push(values.slice(index, index + size));
|
|
1069
|
+
}
|
|
1070
|
+
return chunks;
|
|
1071
|
+
}
|
|
1072
|
+
function extractSalesNavContactId(url) {
|
|
1073
|
+
if (!url) {
|
|
1074
|
+
return null;
|
|
1075
|
+
}
|
|
1076
|
+
try {
|
|
1077
|
+
const parsed = new URL(url);
|
|
1078
|
+
const segments = parsed.pathname.split("/").filter((segment) => segment.length > 0);
|
|
1079
|
+
return segments.length > 0 ? segments[segments.length - 1] : null;
|
|
1080
|
+
}
|
|
1081
|
+
catch {
|
|
1082
|
+
return null;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
444
1085
|
function writeWizardLine(message = "") {
|
|
445
1086
|
process.stdout.write(`${message}\n`);
|
|
446
1087
|
}
|
|
@@ -461,6 +1102,10 @@ function getOrgLabel(session) {
|
|
|
461
1102
|
}
|
|
462
1103
|
return label;
|
|
463
1104
|
}
|
|
1105
|
+
function resolveSessionOrgId(session) {
|
|
1106
|
+
const orgId = session.user.orgId?.trim();
|
|
1107
|
+
return orgId && orgId.length > 0 ? orgId : null;
|
|
1108
|
+
}
|
|
464
1109
|
function writeSessionSummary(session) {
|
|
465
1110
|
const identity = session.user.name?.trim()
|
|
466
1111
|
? `${session.user.name} (${session.user.email})`
|
|
@@ -702,6 +1347,50 @@ async function ensureWizardSession(options) {
|
|
|
702
1347
|
writeWizardLine();
|
|
703
1348
|
return result.session;
|
|
704
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
|
+
}
|
|
705
1394
|
async function fetchWorkspaceLeadSearch(session, requestBody) {
|
|
706
1395
|
const response = await fetch(`${session.apiBaseUrl}/api/cli/leads/search`, {
|
|
707
1396
|
method: "POST",
|
|
@@ -762,10 +1451,14 @@ function decodeSalesNavigatorQueryParam(url) {
|
|
|
762
1451
|
async function createWorkflowLogger(options) {
|
|
763
1452
|
const traceId = options.traceId ?? buildWorkflowTraceId("salesprompter-cli");
|
|
764
1453
|
const logPath = options.logPath;
|
|
1454
|
+
let eventStore = options.eventStore ?? null;
|
|
765
1455
|
await mkdir(path.dirname(logPath), { recursive: true });
|
|
766
1456
|
return {
|
|
767
1457
|
traceId,
|
|
768
1458
|
logPath,
|
|
1459
|
+
setEventStore: (nextEventStore) => {
|
|
1460
|
+
eventStore = nextEventStore;
|
|
1461
|
+
},
|
|
769
1462
|
log: async (event, metadata = {}) => {
|
|
770
1463
|
const entry = {
|
|
771
1464
|
timestamp: new Date().toISOString(),
|
|
@@ -774,10 +1467,111 @@ async function createWorkflowLogger(options) {
|
|
|
774
1467
|
metadata
|
|
775
1468
|
};
|
|
776
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
|
+
}
|
|
777
1486
|
writeProgress(`[${entry.timestamp}] ${event}`);
|
|
778
1487
|
}
|
|
779
1488
|
};
|
|
780
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
|
+
}
|
|
781
1575
|
function summarizeSalesNavigatorQuery(url, appliedFilters) {
|
|
782
1576
|
return {
|
|
783
1577
|
url,
|
|
@@ -1001,6 +1795,13 @@ async function runSalesNavigatorFromProductCategoryWorkflow(options) {
|
|
|
1001
1795
|
return { outPath, payload };
|
|
1002
1796
|
}
|
|
1003
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
|
+
}
|
|
1004
1805
|
let uploaded = null;
|
|
1005
1806
|
if (!options.skipProductUpload) {
|
|
1006
1807
|
await logger.log("linkedin.catalog.upload.started", {
|
|
@@ -1115,7 +1916,57 @@ async function runSalesNavigatorFromProductCategoryWorkflow(options) {
|
|
|
1115
1916
|
summary: crawlSummary
|
|
1116
1917
|
});
|
|
1117
1918
|
}
|
|
1118
|
-
|
|
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
|
+
}
|
|
1119
1970
|
const payload = {
|
|
1120
1971
|
status: "ok",
|
|
1121
1972
|
dryRun: false,
|
|
@@ -1243,6 +2094,26 @@ async function uploadLinkedInProductsCatalog(session, payload, batchSize = 100,
|
|
|
1243
2094
|
}
|
|
1244
2095
|
return { imported, upserted };
|
|
1245
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
|
+
}
|
|
1246
2117
|
function serializeSalesNavigatorFiltersForApi(filters) {
|
|
1247
2118
|
return filters.map((filter) => ({
|
|
1248
2119
|
type: filter.type,
|
|
@@ -1486,6 +2357,55 @@ function isSalesNavigatorAgentBusyError(error) {
|
|
|
1486
2357
|
const message = error instanceof Error ? error.message : String(error);
|
|
1487
2358
|
return /parallel executions limit/i.test(message);
|
|
1488
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
|
+
}
|
|
1489
2409
|
function isSalesNavigatorSessionError(error) {
|
|
1490
2410
|
if (error instanceof SalesNavigatorExportRequestError) {
|
|
1491
2411
|
if (error.errorCode === "invalid_session") {
|
|
@@ -1496,7 +2416,7 @@ function isSalesNavigatorSessionError(error) {
|
|
|
1496
2416
|
}
|
|
1497
2417
|
}
|
|
1498
2418
|
const message = error instanceof Error ? error.message : String(error);
|
|
1499
|
-
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);
|
|
1500
2420
|
}
|
|
1501
2421
|
function isSalesNavigatorResultArtifactError(error) {
|
|
1502
2422
|
if (error instanceof SalesNavigatorExportRequestError && error.errorCode === "phantombuster_result_invalid") {
|
|
@@ -2125,7 +3045,23 @@ async function executeSalesNavigatorCrawlJob(session, jobId, options) {
|
|
|
2125
3045
|
let job = null;
|
|
2126
3046
|
let idlePollCount = 0;
|
|
2127
3047
|
let lastOutcome = null;
|
|
2128
|
-
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
|
+
}
|
|
2129
3065
|
const inFlight = new Map();
|
|
2130
3066
|
let nextSlot = 0;
|
|
2131
3067
|
let noMoreClaimableWork = false;
|
|
@@ -2395,13 +3331,13 @@ async function runProductMarketWizard(rl) {
|
|
|
2395
3331
|
writeWizardLine(` ${buildCommandLine(commandArgs)}`);
|
|
2396
3332
|
}
|
|
2397
3333
|
async function runVendorShortcutWizard(rl) {
|
|
2398
|
-
writeWizardSection("Built-in
|
|
3334
|
+
writeWizardSection("Built-in vendor shortcut", "Use a built-in vendor ICP template and search your workspace lead data.");
|
|
2399
3335
|
const reference = parseCompanyReference(await promptText(rl, "Which company shortcut should I use?", {
|
|
2400
3336
|
required: true
|
|
2401
3337
|
}));
|
|
2402
3338
|
writeWizardLine();
|
|
2403
3339
|
if (reference.vendorTemplate !== "deel") {
|
|
2404
|
-
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.");
|
|
2405
3341
|
}
|
|
2406
3342
|
const market = await promptChoice(rl, "Where do you want to search?", [
|
|
2407
3343
|
{ value: "dach", label: "DACH", description: "Germany, Austria, Switzerland" },
|
|
@@ -2538,14 +3474,14 @@ async function runWizard(options) {
|
|
|
2538
3474
|
},
|
|
2539
3475
|
{
|
|
2540
3476
|
value: "reference-company",
|
|
2541
|
-
label: "Use
|
|
2542
|
-
description: "Generate the saved
|
|
2543
|
-
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"]
|
|
2544
3480
|
},
|
|
2545
3481
|
{
|
|
2546
3482
|
value: "target-company",
|
|
2547
3483
|
label: "Find people at a specific company",
|
|
2548
|
-
description: "Example: find people at
|
|
3484
|
+
description: "Example: find people at company.com",
|
|
2549
3485
|
aliases: ["target company", "company", "find people", "people at a company", "lead generation"]
|
|
2550
3486
|
},
|
|
2551
3487
|
{
|
|
@@ -2710,10 +3646,33 @@ async function fetchHistoricalQueryRows(tables) {
|
|
|
2710
3646
|
}
|
|
2711
3647
|
program
|
|
2712
3648
|
.name("salesprompter")
|
|
2713
|
-
.description("Sales workflow CLI for
|
|
3649
|
+
.description("Sales workflow CLI for guided lead generation, enrichment, scoring, and sync.")
|
|
2714
3650
|
.version(packageVersion)
|
|
2715
3651
|
.option("--json", "Emit compact machine-readable JSON output", false)
|
|
2716
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
|
+
`);
|
|
2717
3676
|
program
|
|
2718
3677
|
.command("auth:login")
|
|
2719
3678
|
.description("Authenticate CLI with a Salesprompter app token, or device flow if the app supports it.")
|
|
@@ -2766,6 +3725,125 @@ program
|
|
|
2766
3725
|
createdAt: verifiedSession.createdAt
|
|
2767
3726
|
});
|
|
2768
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
|
+
});
|
|
2769
3847
|
program
|
|
2770
3848
|
.command("auth:logout")
|
|
2771
3849
|
.description("Remove local CLI auth session.")
|
|
@@ -2776,7 +3854,10 @@ program
|
|
|
2776
3854
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
2777
3855
|
applyGlobalOutputOptions(actionCommand);
|
|
2778
3856
|
const commandName = actionCommand.name();
|
|
2779
|
-
if (commandName.startsWith("auth:") ||
|
|
3857
|
+
if (commandName.startsWith("auth:") ||
|
|
3858
|
+
commandName === "wizard" ||
|
|
3859
|
+
commandName === "llm:ready" ||
|
|
3860
|
+
commandName === "contacts:find-linkedin-urls") {
|
|
2780
3861
|
return;
|
|
2781
3862
|
}
|
|
2782
3863
|
const commandOptions = actionCommand.opts();
|
|
@@ -2789,6 +3870,11 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
|
2789
3870
|
if (shouldBypassAuth()) {
|
|
2790
3871
|
return;
|
|
2791
3872
|
}
|
|
3873
|
+
const envToken = resolveNonInteractiveAuthToken(process.env);
|
|
3874
|
+
if (envToken) {
|
|
3875
|
+
await loginWithToken(envToken, process.env.SALESPROMPTER_API_BASE_URL?.trim());
|
|
3876
|
+
return;
|
|
3877
|
+
}
|
|
2792
3878
|
try {
|
|
2793
3879
|
const session = await requireAuthSession();
|
|
2794
3880
|
if (session.expiresAt !== undefined && Date.now() >= Date.parse(session.expiresAt)) {
|
|
@@ -2796,7 +3882,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
|
2796
3882
|
await ensureInteractiveAuthSession(session.apiBaseUrl);
|
|
2797
3883
|
return;
|
|
2798
3884
|
}
|
|
2799
|
-
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.");
|
|
2800
3886
|
}
|
|
2801
3887
|
}
|
|
2802
3888
|
catch (error) {
|
|
@@ -2812,7 +3898,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
|
2812
3898
|
program
|
|
2813
3899
|
.command("account:resolve")
|
|
2814
3900
|
.description("Resolve a target company into a normalized account profile.")
|
|
2815
|
-
.requiredOption("--domain <domain>", "Company domain like
|
|
3901
|
+
.requiredOption("--domain <domain>", "Company domain like company.com")
|
|
2816
3902
|
.option("--company-name <name>", "Optional company name override")
|
|
2817
3903
|
.option("--icp <path>", "Optional path to ICP JSON for industry/region/title hints")
|
|
2818
3904
|
.requiredOption("--out <path>", "Output file path")
|
|
@@ -2871,7 +3957,7 @@ program
|
|
|
2871
3957
|
program
|
|
2872
3958
|
.command("icp:vendor")
|
|
2873
3959
|
.description("Create a vendor-specific ICP template.")
|
|
2874
|
-
.requiredOption("--vendor <vendor>", "Vendor template name
|
|
3960
|
+
.requiredOption("--vendor <vendor>", "Vendor template name")
|
|
2875
3961
|
.option("--market <market>", "global|europe|dach", "dach")
|
|
2876
3962
|
.option("--out <path>", "Optional output file path")
|
|
2877
3963
|
.action(async (options) => {
|
|
@@ -2886,7 +3972,7 @@ program
|
|
|
2886
3972
|
program
|
|
2887
3973
|
.command("icp:from-historical-queries:bq")
|
|
2888
3974
|
.description("Build a vendor ICP from historical BigQuery query patterns.")
|
|
2889
|
-
.requiredOption("--vendor <vendor>", "Vendor template name
|
|
3975
|
+
.requiredOption("--vendor <vendor>", "Vendor template name")
|
|
2890
3976
|
.option("--market <market>", "global|europe|dach", "dach")
|
|
2891
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")
|
|
2892
3978
|
.option("--search-kind <kind>", "all|people|sales-people|sales-company", "sales-people")
|
|
@@ -2926,7 +4012,7 @@ program
|
|
|
2926
4012
|
.description("Generate leads for a target account or from fallback seeds.")
|
|
2927
4013
|
.requiredOption("--icp <path>", "Path to ICP JSON")
|
|
2928
4014
|
.option("--count <number>", "Number of leads to generate", "10")
|
|
2929
|
-
.option("--domain <domain>", "Target a specific company domain like
|
|
4015
|
+
.option("--domain <domain>", "Target a specific company domain like company.com")
|
|
2930
4016
|
.option("--company-domain <domain>", "Deprecated alias for --domain")
|
|
2931
4017
|
.option("--company-name <name>", "Optional company name override for a targeted domain")
|
|
2932
4018
|
.requiredOption("--out <path>", "Output file path")
|
|
@@ -2975,6 +4061,51 @@ program
|
|
|
2975
4061
|
await writeJsonFile(options.out, scored);
|
|
2976
4062
|
printOutput({ status: "ok", scored: scored.length, out: options.out });
|
|
2977
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
|
+
});
|
|
2978
4109
|
program
|
|
2979
4110
|
.command("sync:crm")
|
|
2980
4111
|
.description("Dry-run sync scored leads into a CRM target.")
|
|
@@ -3004,9 +4135,56 @@ program
|
|
|
3004
4135
|
});
|
|
3005
4136
|
printOutput({ status: "ok", ...result });
|
|
3006
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
|
+
});
|
|
3007
4184
|
program
|
|
3008
4185
|
.command("linkedin-products:scrape")
|
|
3009
|
-
.
|
|
4186
|
+
.alias("market:scrape")
|
|
4187
|
+
.description("Turn a company or product input into a reusable market catalog and upload it to Salesprompter.")
|
|
3010
4188
|
.requiredOption("--input <value>", "Company domain, LinkedIn company URL, LinkedIn product URL, LinkedIn category URL, or LinkedIn product search URL")
|
|
3011
4189
|
.option("--max-pages <number>", "Maximum LinkedIn category pages to fetch", "25")
|
|
3012
4190
|
.option("--limit <number>", "Optional cap on the number of products to keep")
|
|
@@ -3053,7 +4231,8 @@ program
|
|
|
3053
4231
|
});
|
|
3054
4232
|
program
|
|
3055
4233
|
.command("salesnav:from-product-category")
|
|
3056
|
-
.
|
|
4234
|
+
.alias("leads:discover")
|
|
4235
|
+
.description("Start from a market or product input, derive the right buyer searches, and discover leads.")
|
|
3057
4236
|
.requiredOption("--input <value>", "Company domain, LinkedIn company URL, LinkedIn product URL, LinkedIn category URL, or LinkedIn product search URL")
|
|
3058
4237
|
.option("--max-pages <number>", "Maximum LinkedIn category pages to fetch", "25")
|
|
3059
4238
|
.option("--product-limit <number>", "Optional cap on the number of LinkedIn products to inspect")
|
|
@@ -3264,9 +4443,373 @@ program
|
|
|
3264
4443
|
}
|
|
3265
4444
|
printOutput(payload);
|
|
3266
4445
|
});
|
|
4446
|
+
program
|
|
4447
|
+
.command("salesnav:deel-locale-export")
|
|
4448
|
+
.alias("salesnav:vendor-locale-export")
|
|
4449
|
+
.description("Export the Supabase Sales Navigator Deel corpus into German-vs-English outreach backlog files.")
|
|
4450
|
+
.option("--org-id <id>", "Workspace org id. Defaults to the active CLI org.")
|
|
4451
|
+
.option("--limit <number>", "Maximum number of Supabase rows to process", "250000")
|
|
4452
|
+
.option("--page-size <number>", "Supabase page size per request", "1000")
|
|
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)
|
|
4461
|
+
.requiredOption("--out-dir <path>", "Output directory for summary and locale CSV files")
|
|
4462
|
+
.action(async (options) => {
|
|
4463
|
+
const limit = z.coerce.number().int().min(1).max(500000).parse(options.limit);
|
|
4464
|
+
const pageSize = z.coerce.number().int().min(1).max(1000).parse(options.pageSize);
|
|
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);
|
|
4473
|
+
let sessionOrgId = null;
|
|
4474
|
+
if (!shouldBypassAuth()) {
|
|
4475
|
+
const session = await requireAuthSession();
|
|
4476
|
+
sessionOrgId = session.user.orgId ?? null;
|
|
4477
|
+
}
|
|
4478
|
+
const orgId = resolveSalesNavigatorHistoricalBackfillOrgId({
|
|
4479
|
+
explicitOrgId: options.orgId,
|
|
4480
|
+
env: process.env,
|
|
4481
|
+
sessionOrgId
|
|
4482
|
+
});
|
|
4483
|
+
const config = resolveSalesNavigatorSupabaseConfig(process.env);
|
|
4484
|
+
const supabase = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
|
|
4485
|
+
auth: { persistSession: false }
|
|
4486
|
+
});
|
|
4487
|
+
const countResponse = await supabase
|
|
4488
|
+
.from("linkedin_sales_nav_people")
|
|
4489
|
+
.select("id", { count: "exact", head: true })
|
|
4490
|
+
.eq("org_id", orgId);
|
|
4491
|
+
if (countResponse.error) {
|
|
4492
|
+
throw new Error(`Failed to count linkedin_sales_nav_people rows: ${countResponse.error.message}`);
|
|
4493
|
+
}
|
|
4494
|
+
const totalInOrg = countResponse.count ?? 0;
|
|
4495
|
+
const totalToProcess = Math.min(totalInOrg, limit);
|
|
4496
|
+
const baseSlug = `deel-salesnav-${slugify(orgId) || "workspace"}`;
|
|
4497
|
+
const deCsvPath = path.join(options.outDir, `${baseSlug}-de.csv`);
|
|
4498
|
+
const enCsvPath = path.join(options.outDir, `${baseSlug}-en.csv`);
|
|
4499
|
+
const summaryPath = path.join(options.outDir, `${baseSlug}-summary.json`);
|
|
4500
|
+
const samplesPath = path.join(options.outDir, `${baseSlug}-samples.json`);
|
|
4501
|
+
await mkdir(options.outDir, { recursive: true });
|
|
4502
|
+
await writeFile(deCsvPath, `${buildDeelSalesNavCsvHeader()}\n`, "utf8");
|
|
4503
|
+
await writeFile(enCsvPath, `${buildDeelSalesNavCsvHeader()}\n`, "utf8");
|
|
4504
|
+
const localeCounts = { de: 0, en: 0 };
|
|
4505
|
+
let titleMatchedCount = 0;
|
|
4506
|
+
let titleFilteredOutCount = 0;
|
|
4507
|
+
const fieldCounts = {
|
|
4508
|
+
firstName: 0,
|
|
4509
|
+
lastName: 0,
|
|
4510
|
+
fullName: 0,
|
|
4511
|
+
companyName: 0,
|
|
4512
|
+
companyNameCleaned: 0,
|
|
4513
|
+
preferredProfileUrl: 0,
|
|
4514
|
+
linkedinProfileUrl: 0,
|
|
4515
|
+
companyLinkedInHandle: 0,
|
|
4516
|
+
location: 0,
|
|
4517
|
+
companyLocation: 0,
|
|
4518
|
+
searchQuery: 0
|
|
4519
|
+
};
|
|
4520
|
+
const signalFieldCounts = {
|
|
4521
|
+
location: 0,
|
|
4522
|
+
companyLocation: 0,
|
|
4523
|
+
searchQuery: 0,
|
|
4524
|
+
none: 0
|
|
4525
|
+
};
|
|
4526
|
+
const titleCounts = new Map();
|
|
4527
|
+
const samples = {
|
|
4528
|
+
de: [],
|
|
4529
|
+
en: []
|
|
4530
|
+
};
|
|
4531
|
+
const keptRows = [];
|
|
4532
|
+
const selectFields = [
|
|
4533
|
+
"id",
|
|
4534
|
+
"org_id",
|
|
4535
|
+
"run_id",
|
|
4536
|
+
"sales_nav_profile_url",
|
|
4537
|
+
"linkedin_profile_url",
|
|
4538
|
+
"default_profile_url",
|
|
4539
|
+
"full_name",
|
|
4540
|
+
"first_name",
|
|
4541
|
+
"last_name",
|
|
4542
|
+
"company_name",
|
|
4543
|
+
"company_url",
|
|
4544
|
+
"regular_company_url",
|
|
4545
|
+
"title",
|
|
4546
|
+
"industry",
|
|
4547
|
+
"location",
|
|
4548
|
+
"company_location",
|
|
4549
|
+
"search_query",
|
|
4550
|
+
"scraped_at"
|
|
4551
|
+
].join(", ");
|
|
4552
|
+
let processed = 0;
|
|
4553
|
+
let lastSeenId = null;
|
|
4554
|
+
while (processed < totalToProcess) {
|
|
4555
|
+
let query = supabase
|
|
4556
|
+
.from("linkedin_sales_nav_people")
|
|
4557
|
+
.select(selectFields)
|
|
4558
|
+
.eq("org_id", orgId)
|
|
4559
|
+
.order("id", { ascending: true })
|
|
4560
|
+
.limit(Math.min(pageSize, totalToProcess - processed));
|
|
4561
|
+
if (lastSeenId) {
|
|
4562
|
+
query = query.gt("id", lastSeenId);
|
|
4563
|
+
}
|
|
4564
|
+
const response = await query;
|
|
4565
|
+
if (response.error) {
|
|
4566
|
+
throw new Error(`Failed to read linkedin_sales_nav_people rows after ${lastSeenId ?? "start"}: ${response.error.message}`);
|
|
4567
|
+
}
|
|
4568
|
+
const pageRows = (response.data ?? []);
|
|
4569
|
+
if (pageRows.length === 0) {
|
|
4570
|
+
break;
|
|
4571
|
+
}
|
|
4572
|
+
const relevantRows = pageRows.filter((row) => {
|
|
4573
|
+
if (titleFilter === "all") {
|
|
4574
|
+
titleMatchedCount += 1;
|
|
4575
|
+
return true;
|
|
4576
|
+
}
|
|
4577
|
+
const matches = isDeelRelevantSalesNavTitle(row.title);
|
|
4578
|
+
if (matches) {
|
|
4579
|
+
titleMatchedCount += 1;
|
|
4580
|
+
return true;
|
|
4581
|
+
}
|
|
4582
|
+
titleFilteredOutCount += 1;
|
|
4583
|
+
return false;
|
|
4584
|
+
});
|
|
4585
|
+
const preparedRows = relevantRows.map((row) => normalizeDeelSalesNavRow(row));
|
|
4586
|
+
const deRows = preparedRows.filter((row) => row.language === "de");
|
|
4587
|
+
const enRows = preparedRows.filter((row) => row.language === "en");
|
|
4588
|
+
keptRows.push(...preparedRows);
|
|
4589
|
+
if (deRows.length > 0) {
|
|
4590
|
+
await appendFile(deCsvPath, `${buildDeelSalesNavCsvLines(deRows)}\n`, "utf8");
|
|
4591
|
+
}
|
|
4592
|
+
if (enRows.length > 0) {
|
|
4593
|
+
await appendFile(enCsvPath, `${buildDeelSalesNavCsvLines(enRows)}\n`, "utf8");
|
|
4594
|
+
}
|
|
4595
|
+
for (const row of preparedRows) {
|
|
4596
|
+
localeCounts[row.language] += 1;
|
|
4597
|
+
if (row.signalFields.length === 0) {
|
|
4598
|
+
signalFieldCounts.none += 1;
|
|
4599
|
+
}
|
|
4600
|
+
else {
|
|
4601
|
+
for (const field of row.signalFields) {
|
|
4602
|
+
if (field === "location") {
|
|
4603
|
+
signalFieldCounts.location += 1;
|
|
4604
|
+
}
|
|
4605
|
+
else if (field === "companyLocation") {
|
|
4606
|
+
signalFieldCounts.companyLocation += 1;
|
|
4607
|
+
}
|
|
4608
|
+
else if (field === "searchQuery") {
|
|
4609
|
+
signalFieldCounts.searchQuery += 1;
|
|
4610
|
+
}
|
|
4611
|
+
}
|
|
4612
|
+
}
|
|
4613
|
+
if (row.firstName)
|
|
4614
|
+
fieldCounts.firstName += 1;
|
|
4615
|
+
if (row.lastName)
|
|
4616
|
+
fieldCounts.lastName += 1;
|
|
4617
|
+
if (row.fullName)
|
|
4618
|
+
fieldCounts.fullName += 1;
|
|
4619
|
+
if (row.companyName)
|
|
4620
|
+
fieldCounts.companyName += 1;
|
|
4621
|
+
if (row.companyNameCleaned)
|
|
4622
|
+
fieldCounts.companyNameCleaned += 1;
|
|
4623
|
+
if (row.preferredProfileUrl)
|
|
4624
|
+
fieldCounts.preferredProfileUrl += 1;
|
|
4625
|
+
if (row.linkedinProfileUrl)
|
|
4626
|
+
fieldCounts.linkedinProfileUrl += 1;
|
|
4627
|
+
if (row.companyLinkedInHandle)
|
|
4628
|
+
fieldCounts.companyLinkedInHandle += 1;
|
|
4629
|
+
if (row.location)
|
|
4630
|
+
fieldCounts.location += 1;
|
|
4631
|
+
if (row.companyLocation)
|
|
4632
|
+
fieldCounts.companyLocation += 1;
|
|
4633
|
+
if (row.searchQuery)
|
|
4634
|
+
fieldCounts.searchQuery += 1;
|
|
4635
|
+
if (row.title) {
|
|
4636
|
+
titleCounts.set(row.title, (titleCounts.get(row.title) ?? 0) + 1);
|
|
4637
|
+
}
|
|
4638
|
+
if (samples[row.language].length < 25) {
|
|
4639
|
+
samples[row.language].push(row);
|
|
4640
|
+
}
|
|
4641
|
+
}
|
|
4642
|
+
processed += pageRows.length;
|
|
4643
|
+
lastSeenId = pageRows[pageRows.length - 1]?.id ?? lastSeenId;
|
|
4644
|
+
const completedPages = Math.ceil(processed / pageSize);
|
|
4645
|
+
if (completedPages === 1 || processed === totalToProcess || completedPages % 10 === 0) {
|
|
4646
|
+
writeProgress(`Processed ${processed}/${totalToProcess} Deel Sales Navigator rows for org ${orgId}; kept ${titleMatchedCount} after ${titleFilter} title filtering.`);
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
const keptTotal = localeCounts.de + localeCounts.en;
|
|
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
|
+
}
|
|
4745
|
+
const payload = {
|
|
4746
|
+
status: "ok",
|
|
4747
|
+
vendor: "deel",
|
|
4748
|
+
source: "salesnav-supabase",
|
|
4749
|
+
recommendedRouting: "separate-campaigns-by-language",
|
|
4750
|
+
orgId,
|
|
4751
|
+
totalInOrg,
|
|
4752
|
+
scanned: processed,
|
|
4753
|
+
titleFilter,
|
|
4754
|
+
keptAfterTitleFilter: keptTotal,
|
|
4755
|
+
titleMatchedCount,
|
|
4756
|
+
titleFilteredOutCount,
|
|
4757
|
+
truncatedByLimit: totalInOrg > processed,
|
|
4758
|
+
localeCounts,
|
|
4759
|
+
localePercentages: {
|
|
4760
|
+
de: percentage(localeCounts.de),
|
|
4761
|
+
en: percentage(localeCounts.en)
|
|
4762
|
+
},
|
|
4763
|
+
fieldCoverage: {
|
|
4764
|
+
firstName: { count: fieldCounts.firstName, percentage: percentage(fieldCounts.firstName) },
|
|
4765
|
+
lastName: { count: fieldCounts.lastName, percentage: percentage(fieldCounts.lastName) },
|
|
4766
|
+
fullName: { count: fieldCounts.fullName, percentage: percentage(fieldCounts.fullName) },
|
|
4767
|
+
companyName: { count: fieldCounts.companyName, percentage: percentage(fieldCounts.companyName) },
|
|
4768
|
+
companyNameCleaned: {
|
|
4769
|
+
count: fieldCounts.companyNameCleaned,
|
|
4770
|
+
percentage: percentage(fieldCounts.companyNameCleaned)
|
|
4771
|
+
},
|
|
4772
|
+
preferredProfileUrl: {
|
|
4773
|
+
count: fieldCounts.preferredProfileUrl,
|
|
4774
|
+
percentage: percentage(fieldCounts.preferredProfileUrl)
|
|
4775
|
+
},
|
|
4776
|
+
linkedinProfileUrl: {
|
|
4777
|
+
count: fieldCounts.linkedinProfileUrl,
|
|
4778
|
+
percentage: percentage(fieldCounts.linkedinProfileUrl)
|
|
4779
|
+
},
|
|
4780
|
+
companyLinkedInHandle: {
|
|
4781
|
+
count: fieldCounts.companyLinkedInHandle,
|
|
4782
|
+
percentage: percentage(fieldCounts.companyLinkedInHandle)
|
|
4783
|
+
},
|
|
4784
|
+
location: { count: fieldCounts.location, percentage: percentage(fieldCounts.location) },
|
|
4785
|
+
companyLocation: {
|
|
4786
|
+
count: fieldCounts.companyLocation,
|
|
4787
|
+
percentage: percentage(fieldCounts.companyLocation)
|
|
4788
|
+
},
|
|
4789
|
+
searchQuery: { count: fieldCounts.searchQuery, percentage: percentage(fieldCounts.searchQuery) }
|
|
4790
|
+
},
|
|
4791
|
+
signalFieldCounts,
|
|
4792
|
+
topTitles: [...titleCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20),
|
|
4793
|
+
campaignRecommendation: {
|
|
4794
|
+
de: "Only rows with clear DACH signals should enter the German Deel campaign.",
|
|
4795
|
+
en: "Route everything else to a separate English Deel campaign. English is the safer fallback for ambiguous locales."
|
|
4796
|
+
},
|
|
4797
|
+
files: {
|
|
4798
|
+
deCsv: deCsvPath,
|
|
4799
|
+
enCsv: enCsvPath,
|
|
4800
|
+
summary: summaryPath,
|
|
4801
|
+
samples: samplesPath
|
|
4802
|
+
},
|
|
4803
|
+
enrichment: enrichmentSummary
|
|
4804
|
+
};
|
|
4805
|
+
await writeJsonFile(summaryPath, payload);
|
|
4806
|
+
await writeJsonFile(samplesPath, samples);
|
|
4807
|
+
printOutput(payload);
|
|
4808
|
+
});
|
|
3267
4809
|
program
|
|
3268
4810
|
.command("salesnav:crawl")
|
|
3269
|
-
.
|
|
4811
|
+
.alias("search:run")
|
|
4812
|
+
.description("Run a saved people search, split broad result sets when needed, and store the finished output.")
|
|
3270
4813
|
.option("--query-url <url>", "Base LinkedIn Sales Navigator people search URL")
|
|
3271
4814
|
.option("--job-id <id>", "Resume an existing crawl job by id")
|
|
3272
4815
|
.option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
|
|
@@ -3299,6 +4842,7 @@ program
|
|
|
3299
4842
|
const idlePollSeconds = z.coerce.number().int().min(0).max(300).parse(options.idlePollSeconds);
|
|
3300
4843
|
const idleMaxPolls = z.coerce.number().int().min(0).max(10000).parse(options.idleMaxPolls);
|
|
3301
4844
|
const parallelExports = z.coerce.number().int().min(1).max(10).parse(options.parallelExports);
|
|
4845
|
+
const phantomLaneLimit = resolveSalesNavigatorPhantomLaneLimit(process.env);
|
|
3302
4846
|
const effectiveDryRun = Boolean(options.dryRun || shouldBypassAuth());
|
|
3303
4847
|
const logger = await createWorkflowLogger({
|
|
3304
4848
|
logPath: options.logPath ?? buildSalesNavigatorCrawlLogPath(jobId ?? queryUrl ?? "salesnav-crawl")
|
|
@@ -3318,6 +4862,7 @@ program
|
|
|
3318
4862
|
idlePollSeconds,
|
|
3319
4863
|
idleMaxPolls,
|
|
3320
4864
|
parallelExports,
|
|
4865
|
+
phantomLaneLimit,
|
|
3321
4866
|
dryRun: effectiveDryRun
|
|
3322
4867
|
});
|
|
3323
4868
|
if (effectiveDryRun) {
|
|
@@ -3381,6 +4926,13 @@ program
|
|
|
3381
4926
|
throw new Error("Provide exactly one of --query-url or --job-id.");
|
|
3382
4927
|
}
|
|
3383
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
|
+
}
|
|
3384
4936
|
let createResult = null;
|
|
3385
4937
|
let resolvedJobId = jobId ?? null;
|
|
3386
4938
|
if (queryUrl) {
|
|
@@ -3508,23 +5060,42 @@ program
|
|
|
3508
5060
|
});
|
|
3509
5061
|
program
|
|
3510
5062
|
.command("salesnav:crawl:status")
|
|
3511
|
-
.
|
|
5063
|
+
.alias("search:status")
|
|
5064
|
+
.description("Return the current status of a background people-search job.")
|
|
3512
5065
|
.requiredOption("--job-id <id>", "Sales Navigator crawl job id")
|
|
5066
|
+
.option("--events-limit <number>", "How many recent persisted crawl events to include", "25")
|
|
3513
5067
|
.action(async (options) => {
|
|
3514
5068
|
const jobId = z.string().uuid().parse(options.jobId);
|
|
5069
|
+
const eventsLimit = z.coerce.number().int().min(0).max(200).parse(options.eventsLimit);
|
|
3515
5070
|
let session = await requireAuthSession();
|
|
3516
5071
|
const status = await getSalesNavigatorCrawlStatus(session, jobId);
|
|
3517
5072
|
session = status.session;
|
|
3518
|
-
|
|
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
|
+
}
|
|
3519
5088
|
printOutput({
|
|
3520
5089
|
status: "ok",
|
|
3521
5090
|
jobId,
|
|
3522
|
-
job: status.value.job
|
|
5091
|
+
job: status.value.job,
|
|
5092
|
+
recentEvents
|
|
3523
5093
|
});
|
|
3524
5094
|
});
|
|
3525
5095
|
program
|
|
3526
5096
|
.command("salesnav:export")
|
|
3527
|
-
.
|
|
5097
|
+
.alias("search:export")
|
|
5098
|
+
.description("Apply the default people-search filters to one or more search URLs, then export and store the results.")
|
|
3528
5099
|
.requiredOption("--query-url <url>", "Base LinkedIn Sales Navigator people search URL", collectStringOptionValue, [])
|
|
3529
5100
|
.option("--max-results-per-search <number>", "Maximum results allowed for a sliced search", "2500")
|
|
3530
5101
|
.option("--number-of-profiles <number>", "Profiles to export per sliced query", "2500")
|
|
@@ -3585,6 +5156,71 @@ program
|
|
|
3585
5156
|
}
|
|
3586
5157
|
printOutput(payload);
|
|
3587
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
|
+
});
|
|
3588
5224
|
program
|
|
3589
5225
|
.command("leads:lookup:bq")
|
|
3590
5226
|
.description("Build a BigQuery lead lookup from an ICP and optionally execute it with the bq CLI.")
|
|
@@ -3689,7 +5325,7 @@ program
|
|
|
3689
5325
|
program
|
|
3690
5326
|
.command("leadlists:direct-export:bq")
|
|
3691
5327
|
.description("Export upstream leads directly from leadLists -> linkedin_contacts -> linkedin_companies and segment them.")
|
|
3692
|
-
.requiredOption("--vendor <vendor>", "Vendor template name
|
|
5328
|
+
.requiredOption("--vendor <vendor>", "Vendor template name")
|
|
3693
5329
|
.option("--market <market>", "global|europe|dach", "dach")
|
|
3694
5330
|
.option("--limit <number>", "Max rows to export", "20000")
|
|
3695
5331
|
.requiredOption("--out-dir <path>", "Output directory for raw and segmented files")
|
|
@@ -3733,10 +5369,99 @@ program
|
|
|
3733
5369
|
sqlOut: options.sqlOut ?? null
|
|
3734
5370
|
});
|
|
3735
5371
|
});
|
|
5372
|
+
program
|
|
5373
|
+
.command("leadlists:deel-outreach:bq")
|
|
5374
|
+
.alias("leadlists:vendor-outreach:bq")
|
|
5375
|
+
.description("Build Instantly-ready Deel outreach batches from leadPool_new with lead-list provenance, split into German vs English.")
|
|
5376
|
+
.option("--market <market>", "global|europe|dach", "global")
|
|
5377
|
+
.option("--limit <number>", "Max rows to export", "200000")
|
|
5378
|
+
.option("--min-email-score <number>", "Minimum email score to keep", "70")
|
|
5379
|
+
.requiredOption("--out-dir <path>", "Output directory for raw rows, packs, and locale batches")
|
|
5380
|
+
.option("--sql-out <path>", "Optional file path for the generated SQL")
|
|
5381
|
+
.option("--campaign-id <id>", "Fallback Instantly campaign id for all locales")
|
|
5382
|
+
.option("--campaign-id-de <id>", "Instantly campaign id for German/DACH leads")
|
|
5383
|
+
.option("--campaign-id-en <id>", "Instantly campaign id for English/non-DACH leads")
|
|
5384
|
+
.option("--apply", "Create leads in Instantly instead of export-only mode", false)
|
|
5385
|
+
.option("--allow-duplicates", "Do not skip emails already present in the Instantly campaign", false)
|
|
5386
|
+
.action(async (options) => {
|
|
5387
|
+
const market = z.enum(["global", "europe", "dach"]).parse(options.market);
|
|
5388
|
+
const limit = z.coerce.number().int().min(1).max(500000).parse(options.limit);
|
|
5389
|
+
const minEmailScore = z.coerce.number().int().min(0).max(100).parse(options.minEmailScore);
|
|
5390
|
+
const sql = buildDeelOutreachExportSql({ market, limit, minEmailScore });
|
|
5391
|
+
if (options.sqlOut) {
|
|
5392
|
+
await writeTextFile(options.sqlOut, `${sql}\n`);
|
|
5393
|
+
}
|
|
5394
|
+
const rows = await runBigQueryRows(sql, { maxRows: limit });
|
|
5395
|
+
const normalizedRows = normalizeDeelOutreachRows(rows);
|
|
5396
|
+
const pack = buildDeelOutreachPack(market, normalizedRows);
|
|
5397
|
+
const baseSlug = `deel-outreach-${market}`;
|
|
5398
|
+
const rawPath = path.join(options.outDir, `${baseSlug}-raw.json`);
|
|
5399
|
+
const packPath = path.join(options.outDir, `${baseSlug}-pack.json`);
|
|
5400
|
+
const allPath = path.join(options.outDir, `${baseSlug}-all.json`);
|
|
5401
|
+
const dePath = path.join(options.outDir, `${baseSlug}-de.json`);
|
|
5402
|
+
const enPath = path.join(options.outDir, `${baseSlug}-en.json`);
|
|
5403
|
+
await writeJsonFile(rawPath, normalizedRows);
|
|
5404
|
+
await writeJsonFile(packPath, pack);
|
|
5405
|
+
await writeJsonFile(allPath, [...pack.locales.de, ...pack.locales.en]);
|
|
5406
|
+
await writeJsonFile(dePath, pack.locales.de);
|
|
5407
|
+
await writeJsonFile(enPath, pack.locales.en);
|
|
5408
|
+
const syncResults = [];
|
|
5409
|
+
const routes = [
|
|
5410
|
+
{
|
|
5411
|
+
locale: "de",
|
|
5412
|
+
campaignId: options.campaignIdDe ?? options.campaignId,
|
|
5413
|
+
leads: pack.locales.de
|
|
5414
|
+
},
|
|
5415
|
+
{
|
|
5416
|
+
locale: "en",
|
|
5417
|
+
campaignId: options.campaignIdEn ?? options.campaignId,
|
|
5418
|
+
leads: pack.locales.en
|
|
5419
|
+
}
|
|
5420
|
+
];
|
|
5421
|
+
for (const route of routes) {
|
|
5422
|
+
if (!route.campaignId || route.leads.length === 0) {
|
|
5423
|
+
continue;
|
|
5424
|
+
}
|
|
5425
|
+
const result = await syncProvider.sync("instantly", route.leads, {
|
|
5426
|
+
apply: Boolean(options.apply),
|
|
5427
|
+
instantlyCampaignId: route.campaignId,
|
|
5428
|
+
allowDuplicates: Boolean(options.allowDuplicates)
|
|
5429
|
+
});
|
|
5430
|
+
syncResults.push({
|
|
5431
|
+
locale: route.locale,
|
|
5432
|
+
campaignId: route.campaignId,
|
|
5433
|
+
synced: result.synced,
|
|
5434
|
+
skipped: result.skipped ?? 0,
|
|
5435
|
+
dryRun: result.dryRun,
|
|
5436
|
+
provider: result.provider ?? "instantly"
|
|
5437
|
+
});
|
|
5438
|
+
}
|
|
5439
|
+
printOutput({
|
|
5440
|
+
status: "ok",
|
|
5441
|
+
vendor: "deel",
|
|
5442
|
+
market,
|
|
5443
|
+
limit,
|
|
5444
|
+
minEmailScore,
|
|
5445
|
+
rowCount: normalizedRows.length,
|
|
5446
|
+
hitLimit: normalizedRows.length === limit,
|
|
5447
|
+
localeCounts: pack.summary.localeCounts,
|
|
5448
|
+
segmentCounts: pack.summary.segmentCounts,
|
|
5449
|
+
averageEmailScoreByLocale: pack.summary.averageEmailScoreByLocale,
|
|
5450
|
+
recommendedRouting: "separate-campaigns-by-language",
|
|
5451
|
+
outDir: options.outDir,
|
|
5452
|
+
raw: rawPath,
|
|
5453
|
+
pack: packPath,
|
|
5454
|
+
all: allPath,
|
|
5455
|
+
german: dePath,
|
|
5456
|
+
english: enPath,
|
|
5457
|
+
syncResults,
|
|
5458
|
+
sqlOut: options.sqlOut ?? null
|
|
5459
|
+
});
|
|
5460
|
+
});
|
|
3736
5461
|
program
|
|
3737
5462
|
.command("leadlists:funnel:bq")
|
|
3738
5463
|
.description("Build an upstream lead-list funnel report for a vendor/market.")
|
|
3739
|
-
.requiredOption("--vendor <vendor>", "Vendor template name
|
|
5464
|
+
.requiredOption("--vendor <vendor>", "Vendor template name")
|
|
3740
5465
|
.option("--market <market>", "global|europe|dach", "dach")
|
|
3741
5466
|
.requiredOption("--out <path>", "Output report path")
|
|
3742
5467
|
.action(async (options) => {
|