salesprompter-cli 0.1.22 → 0.1.23

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 CHANGED
@@ -33,6 +33,12 @@ salesprompter auth:whoami
33
33
  # Product/category to leads workflow
34
34
  salesprompter salesnav:from-product-category --input "https://www.linkedin.com/company/example/"
35
35
 
36
+ # Build Instantly-ready Deel outreach batches with German vs English splits
37
+ salesprompter leadlists:deel-outreach:bq --market global --out-dir ./data/deel-outreach
38
+
39
+ # Export the Supabase Sales Navigator Deel corpus into German vs English backlog files
40
+ salesprompter salesnav:deel-locale-export --org-id "<org-id>" --out-dir ./data/deel-salesnav
41
+
36
42
  # Run a Sales Navigator crawl from a query URL
37
43
  salesprompter salesnav:crawl --query-url "https://www.linkedin.com/sales/search/people?query=..."
38
44
 
package/dist/cli.js CHANGED
@@ -14,6 +14,8 @@ 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 { 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";
@@ -359,6 +361,24 @@ function slugify(value) {
359
361
  .replace(/^-+|-+$/g, "")
360
362
  .replace(/-{2,}/g, "-");
361
363
  }
364
+ function resolveSalesNavigatorSupabaseConfig(env = process.env) {
365
+ const supabaseUrl = env.SALESPROMPTER_SUPABASE_URL?.trim() || env.NEXT_PUBLIC_SUPABASE_URL?.trim() || "";
366
+ const supabaseServiceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY?.trim() || "";
367
+ const missing = [];
368
+ if (supabaseUrl.length === 0) {
369
+ missing.push("SALESPROMPTER_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL");
370
+ }
371
+ if (supabaseServiceRoleKey.length === 0) {
372
+ missing.push("SUPABASE_SERVICE_ROLE_KEY");
373
+ }
374
+ if (missing.length > 0) {
375
+ throw new Error(`Missing required environment variables for Sales Navigator Supabase export: ${missing.join(", ")}`);
376
+ }
377
+ return {
378
+ supabaseUrl,
379
+ supabaseServiceRoleKey
380
+ };
381
+ }
362
382
  function normalizeDomainInput(value) {
363
383
  return value
364
384
  .trim()
@@ -441,6 +461,26 @@ function parseCompanyReference(value) {
441
461
  })
442
462
  };
443
463
  }
464
+ function chunkArray(values, size) {
465
+ const chunks = [];
466
+ for (let index = 0; index < values.length; index += size) {
467
+ chunks.push(values.slice(index, index + size));
468
+ }
469
+ return chunks;
470
+ }
471
+ function extractSalesNavContactId(url) {
472
+ if (!url) {
473
+ return null;
474
+ }
475
+ try {
476
+ const parsed = new URL(url);
477
+ const segments = parsed.pathname.split("/").filter((segment) => segment.length > 0);
478
+ return segments.length > 0 ? segments[segments.length - 1] : null;
479
+ }
480
+ catch {
481
+ return null;
482
+ }
483
+ }
444
484
  function writeWizardLine(message = "") {
445
485
  process.stdout.write(`${message}\n`);
446
486
  }
@@ -3264,6 +3304,257 @@ program
3264
3304
  }
3265
3305
  printOutput(payload);
3266
3306
  });
3307
+ program
3308
+ .command("salesnav:deel-locale-export")
3309
+ .description("Export the Supabase Sales Navigator Deel corpus into German-vs-English outreach backlog files.")
3310
+ .option("--org-id <id>", "Workspace org id. Defaults to the active CLI org.")
3311
+ .option("--limit <number>", "Maximum number of Supabase rows to process", "250000")
3312
+ .option("--page-size <number>", "Supabase page size per request", "1000")
3313
+ .option("--title-filter <mode>", "deel-hr|all", "deel-hr")
3314
+ .requiredOption("--out-dir <path>", "Output directory for summary and locale CSV files")
3315
+ .action(async (options) => {
3316
+ const limit = z.coerce.number().int().min(1).max(500000).parse(options.limit);
3317
+ const pageSize = z.coerce.number().int().min(1).max(1000).parse(options.pageSize);
3318
+ const titleFilter = z.enum(["deel-hr", "all"]).parse(options.titleFilter);
3319
+ let sessionOrgId = null;
3320
+ if (!shouldBypassAuth()) {
3321
+ const session = await requireAuthSession();
3322
+ sessionOrgId = session.user.orgId ?? null;
3323
+ }
3324
+ const orgId = resolveSalesNavigatorHistoricalBackfillOrgId({
3325
+ explicitOrgId: options.orgId,
3326
+ env: process.env,
3327
+ sessionOrgId
3328
+ });
3329
+ const config = resolveSalesNavigatorSupabaseConfig(process.env);
3330
+ const supabase = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
3331
+ auth: { persistSession: false }
3332
+ });
3333
+ const countResponse = await supabase
3334
+ .from("linkedin_sales_nav_people")
3335
+ .select("id", { count: "exact", head: true })
3336
+ .eq("org_id", orgId);
3337
+ if (countResponse.error) {
3338
+ throw new Error(`Failed to count linkedin_sales_nav_people rows: ${countResponse.error.message}`);
3339
+ }
3340
+ const totalInOrg = countResponse.count ?? 0;
3341
+ const totalToProcess = Math.min(totalInOrg, limit);
3342
+ const baseSlug = `deel-salesnav-${slugify(orgId) || "workspace"}`;
3343
+ const deCsvPath = path.join(options.outDir, `${baseSlug}-de.csv`);
3344
+ const enCsvPath = path.join(options.outDir, `${baseSlug}-en.csv`);
3345
+ const summaryPath = path.join(options.outDir, `${baseSlug}-summary.json`);
3346
+ const samplesPath = path.join(options.outDir, `${baseSlug}-samples.json`);
3347
+ await mkdir(options.outDir, { recursive: true });
3348
+ await writeFile(deCsvPath, `${buildDeelSalesNavCsvHeader()}\n`, "utf8");
3349
+ await writeFile(enCsvPath, `${buildDeelSalesNavCsvHeader()}\n`, "utf8");
3350
+ const localeCounts = { de: 0, en: 0 };
3351
+ let titleMatchedCount = 0;
3352
+ let titleFilteredOutCount = 0;
3353
+ const fieldCounts = {
3354
+ firstName: 0,
3355
+ lastName: 0,
3356
+ fullName: 0,
3357
+ companyName: 0,
3358
+ companyNameCleaned: 0,
3359
+ preferredProfileUrl: 0,
3360
+ linkedinProfileUrl: 0,
3361
+ companyLinkedInHandle: 0,
3362
+ location: 0,
3363
+ companyLocation: 0,
3364
+ searchQuery: 0
3365
+ };
3366
+ const signalFieldCounts = {
3367
+ location: 0,
3368
+ companyLocation: 0,
3369
+ searchQuery: 0,
3370
+ none: 0
3371
+ };
3372
+ const titleCounts = new Map();
3373
+ const samples = {
3374
+ de: [],
3375
+ en: []
3376
+ };
3377
+ const selectFields = [
3378
+ "id",
3379
+ "org_id",
3380
+ "run_id",
3381
+ "sales_nav_profile_url",
3382
+ "linkedin_profile_url",
3383
+ "default_profile_url",
3384
+ "full_name",
3385
+ "first_name",
3386
+ "last_name",
3387
+ "company_name",
3388
+ "company_url",
3389
+ "regular_company_url",
3390
+ "title",
3391
+ "industry",
3392
+ "location",
3393
+ "company_location",
3394
+ "search_query",
3395
+ "scraped_at"
3396
+ ].join(", ");
3397
+ let processed = 0;
3398
+ let lastSeenId = null;
3399
+ while (processed < totalToProcess) {
3400
+ let query = supabase
3401
+ .from("linkedin_sales_nav_people")
3402
+ .select(selectFields)
3403
+ .eq("org_id", orgId)
3404
+ .order("id", { ascending: true })
3405
+ .limit(Math.min(pageSize, totalToProcess - processed));
3406
+ if (lastSeenId) {
3407
+ query = query.gt("id", lastSeenId);
3408
+ }
3409
+ const response = await query;
3410
+ if (response.error) {
3411
+ throw new Error(`Failed to read linkedin_sales_nav_people rows after ${lastSeenId ?? "start"}: ${response.error.message}`);
3412
+ }
3413
+ const pageRows = (response.data ?? []);
3414
+ if (pageRows.length === 0) {
3415
+ break;
3416
+ }
3417
+ const relevantRows = pageRows.filter((row) => {
3418
+ if (titleFilter === "all") {
3419
+ titleMatchedCount += 1;
3420
+ return true;
3421
+ }
3422
+ const matches = isDeelRelevantSalesNavTitle(row.title);
3423
+ if (matches) {
3424
+ titleMatchedCount += 1;
3425
+ return true;
3426
+ }
3427
+ titleFilteredOutCount += 1;
3428
+ return false;
3429
+ });
3430
+ const preparedRows = relevantRows.map((row) => normalizeDeelSalesNavRow(row));
3431
+ const deRows = preparedRows.filter((row) => row.language === "de");
3432
+ const enRows = preparedRows.filter((row) => row.language === "en");
3433
+ if (deRows.length > 0) {
3434
+ await appendFile(deCsvPath, `${buildDeelSalesNavCsvLines(deRows)}\n`, "utf8");
3435
+ }
3436
+ if (enRows.length > 0) {
3437
+ await appendFile(enCsvPath, `${buildDeelSalesNavCsvLines(enRows)}\n`, "utf8");
3438
+ }
3439
+ for (const row of preparedRows) {
3440
+ localeCounts[row.language] += 1;
3441
+ if (row.signalFields.length === 0) {
3442
+ signalFieldCounts.none += 1;
3443
+ }
3444
+ else {
3445
+ for (const field of row.signalFields) {
3446
+ if (field === "location") {
3447
+ signalFieldCounts.location += 1;
3448
+ }
3449
+ else if (field === "companyLocation") {
3450
+ signalFieldCounts.companyLocation += 1;
3451
+ }
3452
+ else if (field === "searchQuery") {
3453
+ signalFieldCounts.searchQuery += 1;
3454
+ }
3455
+ }
3456
+ }
3457
+ if (row.firstName)
3458
+ fieldCounts.firstName += 1;
3459
+ if (row.lastName)
3460
+ fieldCounts.lastName += 1;
3461
+ if (row.fullName)
3462
+ fieldCounts.fullName += 1;
3463
+ if (row.companyName)
3464
+ fieldCounts.companyName += 1;
3465
+ if (row.companyNameCleaned)
3466
+ fieldCounts.companyNameCleaned += 1;
3467
+ if (row.preferredProfileUrl)
3468
+ fieldCounts.preferredProfileUrl += 1;
3469
+ if (row.linkedinProfileUrl)
3470
+ fieldCounts.linkedinProfileUrl += 1;
3471
+ if (row.companyLinkedInHandle)
3472
+ fieldCounts.companyLinkedInHandle += 1;
3473
+ if (row.location)
3474
+ fieldCounts.location += 1;
3475
+ if (row.companyLocation)
3476
+ fieldCounts.companyLocation += 1;
3477
+ if (row.searchQuery)
3478
+ fieldCounts.searchQuery += 1;
3479
+ if (row.title) {
3480
+ titleCounts.set(row.title, (titleCounts.get(row.title) ?? 0) + 1);
3481
+ }
3482
+ if (samples[row.language].length < 25) {
3483
+ samples[row.language].push(row);
3484
+ }
3485
+ }
3486
+ processed += pageRows.length;
3487
+ lastSeenId = pageRows[pageRows.length - 1]?.id ?? lastSeenId;
3488
+ const completedPages = Math.ceil(processed / pageSize);
3489
+ if (completedPages === 1 || processed === totalToProcess || completedPages % 10 === 0) {
3490
+ writeProgress(`Processed ${processed}/${totalToProcess} Deel Sales Navigator rows for org ${orgId}; kept ${titleMatchedCount} after ${titleFilter} title filtering.`);
3491
+ }
3492
+ }
3493
+ const keptTotal = localeCounts.de + localeCounts.en;
3494
+ const percentage = (count, base = keptTotal) => base > 0 ? Number(((count / base) * 100).toFixed(2)) : 0;
3495
+ const payload = {
3496
+ status: "ok",
3497
+ vendor: "deel",
3498
+ source: "salesnav-supabase",
3499
+ recommendedRouting: "separate-campaigns-by-language",
3500
+ orgId,
3501
+ totalInOrg,
3502
+ scanned: processed,
3503
+ titleFilter,
3504
+ keptAfterTitleFilter: keptTotal,
3505
+ titleMatchedCount,
3506
+ titleFilteredOutCount,
3507
+ truncatedByLimit: totalInOrg > processed,
3508
+ localeCounts,
3509
+ localePercentages: {
3510
+ de: percentage(localeCounts.de),
3511
+ en: percentage(localeCounts.en)
3512
+ },
3513
+ fieldCoverage: {
3514
+ firstName: { count: fieldCounts.firstName, percentage: percentage(fieldCounts.firstName) },
3515
+ lastName: { count: fieldCounts.lastName, percentage: percentage(fieldCounts.lastName) },
3516
+ fullName: { count: fieldCounts.fullName, percentage: percentage(fieldCounts.fullName) },
3517
+ companyName: { count: fieldCounts.companyName, percentage: percentage(fieldCounts.companyName) },
3518
+ companyNameCleaned: {
3519
+ count: fieldCounts.companyNameCleaned,
3520
+ percentage: percentage(fieldCounts.companyNameCleaned)
3521
+ },
3522
+ preferredProfileUrl: {
3523
+ count: fieldCounts.preferredProfileUrl,
3524
+ percentage: percentage(fieldCounts.preferredProfileUrl)
3525
+ },
3526
+ linkedinProfileUrl: {
3527
+ count: fieldCounts.linkedinProfileUrl,
3528
+ percentage: percentage(fieldCounts.linkedinProfileUrl)
3529
+ },
3530
+ companyLinkedInHandle: {
3531
+ count: fieldCounts.companyLinkedInHandle,
3532
+ percentage: percentage(fieldCounts.companyLinkedInHandle)
3533
+ },
3534
+ location: { count: fieldCounts.location, percentage: percentage(fieldCounts.location) },
3535
+ companyLocation: {
3536
+ count: fieldCounts.companyLocation,
3537
+ percentage: percentage(fieldCounts.companyLocation)
3538
+ },
3539
+ searchQuery: { count: fieldCounts.searchQuery, percentage: percentage(fieldCounts.searchQuery) }
3540
+ },
3541
+ signalFieldCounts,
3542
+ topTitles: [...titleCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20),
3543
+ campaignRecommendation: {
3544
+ de: "Only rows with clear DACH signals should enter the German Deel campaign.",
3545
+ en: "Route everything else to a separate English Deel campaign. English is the safer fallback for ambiguous locales."
3546
+ },
3547
+ files: {
3548
+ deCsv: deCsvPath,
3549
+ enCsv: enCsvPath,
3550
+ summary: summaryPath,
3551
+ samples: samplesPath
3552
+ }
3553
+ };
3554
+ await writeJsonFile(summaryPath, payload);
3555
+ await writeJsonFile(samplesPath, samples);
3556
+ printOutput(payload);
3557
+ });
3267
3558
  program
3268
3559
  .command("salesnav:crawl")
3269
3560
  .description("Adaptively split broad LinkedIn Sales Navigator people searches into exportable slices and store every finished slice through Salesprompter.")
@@ -3733,6 +4024,94 @@ program
3733
4024
  sqlOut: options.sqlOut ?? null
3734
4025
  });
3735
4026
  });
4027
+ program
4028
+ .command("leadlists:deel-outreach:bq")
4029
+ .description("Build Instantly-ready Deel outreach batches from leadPool_new with lead-list provenance, split into German vs English.")
4030
+ .option("--market <market>", "global|europe|dach", "global")
4031
+ .option("--limit <number>", "Max rows to export", "200000")
4032
+ .option("--min-email-score <number>", "Minimum email score to keep", "70")
4033
+ .requiredOption("--out-dir <path>", "Output directory for raw rows, packs, and locale batches")
4034
+ .option("--sql-out <path>", "Optional file path for the generated SQL")
4035
+ .option("--campaign-id <id>", "Fallback Instantly campaign id for all locales")
4036
+ .option("--campaign-id-de <id>", "Instantly campaign id for German/DACH leads")
4037
+ .option("--campaign-id-en <id>", "Instantly campaign id for English/non-DACH leads")
4038
+ .option("--apply", "Create leads in Instantly instead of export-only mode", false)
4039
+ .option("--allow-duplicates", "Do not skip emails already present in the Instantly campaign", false)
4040
+ .action(async (options) => {
4041
+ const market = z.enum(["global", "europe", "dach"]).parse(options.market);
4042
+ const limit = z.coerce.number().int().min(1).max(500000).parse(options.limit);
4043
+ const minEmailScore = z.coerce.number().int().min(0).max(100).parse(options.minEmailScore);
4044
+ const sql = buildDeelOutreachExportSql({ market, limit, minEmailScore });
4045
+ if (options.sqlOut) {
4046
+ await writeTextFile(options.sqlOut, `${sql}\n`);
4047
+ }
4048
+ const rows = await runBigQueryRows(sql, { maxRows: limit });
4049
+ const normalizedRows = normalizeDeelOutreachRows(rows);
4050
+ const pack = buildDeelOutreachPack(market, normalizedRows);
4051
+ const baseSlug = `deel-outreach-${market}`;
4052
+ const rawPath = path.join(options.outDir, `${baseSlug}-raw.json`);
4053
+ const packPath = path.join(options.outDir, `${baseSlug}-pack.json`);
4054
+ const allPath = path.join(options.outDir, `${baseSlug}-all.json`);
4055
+ const dePath = path.join(options.outDir, `${baseSlug}-de.json`);
4056
+ const enPath = path.join(options.outDir, `${baseSlug}-en.json`);
4057
+ await writeJsonFile(rawPath, normalizedRows);
4058
+ await writeJsonFile(packPath, pack);
4059
+ await writeJsonFile(allPath, [...pack.locales.de, ...pack.locales.en]);
4060
+ await writeJsonFile(dePath, pack.locales.de);
4061
+ await writeJsonFile(enPath, pack.locales.en);
4062
+ const syncResults = [];
4063
+ const routes = [
4064
+ {
4065
+ locale: "de",
4066
+ campaignId: options.campaignIdDe ?? options.campaignId,
4067
+ leads: pack.locales.de
4068
+ },
4069
+ {
4070
+ locale: "en",
4071
+ campaignId: options.campaignIdEn ?? options.campaignId,
4072
+ leads: pack.locales.en
4073
+ }
4074
+ ];
4075
+ for (const route of routes) {
4076
+ if (!route.campaignId || route.leads.length === 0) {
4077
+ continue;
4078
+ }
4079
+ const result = await syncProvider.sync("instantly", route.leads, {
4080
+ apply: Boolean(options.apply),
4081
+ instantlyCampaignId: route.campaignId,
4082
+ allowDuplicates: Boolean(options.allowDuplicates)
4083
+ });
4084
+ syncResults.push({
4085
+ locale: route.locale,
4086
+ campaignId: route.campaignId,
4087
+ synced: result.synced,
4088
+ skipped: result.skipped ?? 0,
4089
+ dryRun: result.dryRun,
4090
+ provider: result.provider ?? "instantly"
4091
+ });
4092
+ }
4093
+ printOutput({
4094
+ status: "ok",
4095
+ vendor: "deel",
4096
+ market,
4097
+ limit,
4098
+ minEmailScore,
4099
+ rowCount: normalizedRows.length,
4100
+ hitLimit: normalizedRows.length === limit,
4101
+ localeCounts: pack.summary.localeCounts,
4102
+ segmentCounts: pack.summary.segmentCounts,
4103
+ averageEmailScoreByLocale: pack.summary.averageEmailScoreByLocale,
4104
+ recommendedRouting: "separate-campaigns-by-language",
4105
+ outDir: options.outDir,
4106
+ raw: rawPath,
4107
+ pack: packPath,
4108
+ all: allPath,
4109
+ german: dePath,
4110
+ english: enPath,
4111
+ syncResults,
4112
+ sqlOut: options.sqlOut ?? null
4113
+ });
4114
+ });
3736
4115
  program
3737
4116
  .command("leadlists:funnel:bq")
3738
4117
  .description("Build an upstream lead-list funnel report for a vendor/market.")