salesprompter-cli 0.1.42 → 0.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +704 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -407,6 +407,8 @@ const helpAliasByCommandName = new Map([
|
|
|
407
407
|
["companies:find-linkedin-urls", "companies:resolve-linkedin-urls"],
|
|
408
408
|
["contacts:process-emails", "contacts:resolve-emails"],
|
|
409
409
|
["linkedin-companies:backfill", "companies:enrich"],
|
|
410
|
+
["linkedin-companies:scrape-local", "companies:scrape-linkedin"],
|
|
411
|
+
["dealroom-companies:scrape-local", "companies:scrape-dealroom"],
|
|
410
412
|
["linkedin-products:scrape", "market:scrape"],
|
|
411
413
|
["salesnav:from-product-category", "leads:discover"],
|
|
412
414
|
["salesnav:crawl", "search:run"],
|
|
@@ -444,6 +446,8 @@ const helpVisibleCommandNames = new Set([
|
|
|
444
446
|
"sync:crm",
|
|
445
447
|
"sync:outreach",
|
|
446
448
|
"linkedin-companies:backfill",
|
|
449
|
+
"linkedin-companies:scrape-local",
|
|
450
|
+
"dealroom-companies:scrape-local",
|
|
447
451
|
"linkedin-products:scrape",
|
|
448
452
|
"salesnav:from-product-category",
|
|
449
453
|
"salesnav:crawl",
|
|
@@ -7365,6 +7369,632 @@ function sanitizeForPostgresJson(value) {
|
|
|
7365
7369
|
function sanitizeOptionalPostgresText(value) {
|
|
7366
7370
|
return typeof value === "string" ? stripPostgresUnsafeStringBytes(value) : null;
|
|
7367
7371
|
}
|
|
7372
|
+
function normalizeLinkedInCompanyScrapeUrl(rawUrl) {
|
|
7373
|
+
let parsed;
|
|
7374
|
+
try {
|
|
7375
|
+
parsed = new URL(rawUrl.startsWith("http") ? rawUrl : `https://${rawUrl}`);
|
|
7376
|
+
}
|
|
7377
|
+
catch {
|
|
7378
|
+
throw new Error(`Invalid LinkedIn company URL: ${rawUrl}`);
|
|
7379
|
+
}
|
|
7380
|
+
const idMatch = parsed.pathname.match(/\/(?:sales\/)?company\/([0-9]+)/i);
|
|
7381
|
+
if (!idMatch) {
|
|
7382
|
+
throw new Error(`LinkedIn company URL must include a numeric company id: ${rawUrl}`);
|
|
7383
|
+
}
|
|
7384
|
+
const id = Number(idMatch[1]);
|
|
7385
|
+
if (!Number.isFinite(id)) {
|
|
7386
|
+
throw new Error(`LinkedIn company URL has an invalid company id: ${rawUrl}`);
|
|
7387
|
+
}
|
|
7388
|
+
return {
|
|
7389
|
+
id,
|
|
7390
|
+
sourceUrl: parsed.toString(),
|
|
7391
|
+
companyUrl: `https://www.linkedin.com/company/${id}`,
|
|
7392
|
+
salesNavigatorLink: `https://www.linkedin.com/sales/company/${id}/`,
|
|
7393
|
+
};
|
|
7394
|
+
}
|
|
7395
|
+
function normalizeDealroomCompanyScrapeUrl(rawUrl) {
|
|
7396
|
+
let parsed;
|
|
7397
|
+
try {
|
|
7398
|
+
parsed = new URL(rawUrl.startsWith("http") ? rawUrl : `https://${rawUrl}`);
|
|
7399
|
+
}
|
|
7400
|
+
catch {
|
|
7401
|
+
throw new Error(`Invalid Dealroom company URL: ${rawUrl}`);
|
|
7402
|
+
}
|
|
7403
|
+
const slugMatch = parsed.pathname.match(/\/companies\/([^/?#]+)/i);
|
|
7404
|
+
if (!slugMatch) {
|
|
7405
|
+
throw new Error(`Dealroom company URL must include /companies/<slug>: ${rawUrl}`);
|
|
7406
|
+
}
|
|
7407
|
+
const slug = decodeURIComponent(slugMatch[1] || "").trim();
|
|
7408
|
+
if (!slug) {
|
|
7409
|
+
throw new Error(`Dealroom company URL has an invalid company slug: ${rawUrl}`);
|
|
7410
|
+
}
|
|
7411
|
+
return {
|
|
7412
|
+
slug,
|
|
7413
|
+
sourceUrl: parsed.toString(),
|
|
7414
|
+
companyUrl: `https://app.dealroom.co/companies/${encodeURIComponent(slug)}`,
|
|
7415
|
+
};
|
|
7416
|
+
}
|
|
7417
|
+
function normalizeLocalCompanyDomain(website) {
|
|
7418
|
+
if (!website) {
|
|
7419
|
+
return null;
|
|
7420
|
+
}
|
|
7421
|
+
try {
|
|
7422
|
+
const url = new URL(website.startsWith("http") ? website : `https://${website}`);
|
|
7423
|
+
return url.hostname.replace(/^www\./i, "").toLowerCase();
|
|
7424
|
+
}
|
|
7425
|
+
catch {
|
|
7426
|
+
return null;
|
|
7427
|
+
}
|
|
7428
|
+
}
|
|
7429
|
+
function normalizeDealroomWebsite(value) {
|
|
7430
|
+
if (!value) {
|
|
7431
|
+
return null;
|
|
7432
|
+
}
|
|
7433
|
+
const trimmed = value.trim();
|
|
7434
|
+
if (!trimmed || /^view job openings$/i.test(trimmed)) {
|
|
7435
|
+
return null;
|
|
7436
|
+
}
|
|
7437
|
+
try {
|
|
7438
|
+
return new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`).toString();
|
|
7439
|
+
}
|
|
7440
|
+
catch {
|
|
7441
|
+
return trimmed;
|
|
7442
|
+
}
|
|
7443
|
+
}
|
|
7444
|
+
function parseLinkedInAboutTextValue(lines, label) {
|
|
7445
|
+
const labelIndex = lines.findIndex((line) => line.toLowerCase() === label.toLowerCase());
|
|
7446
|
+
if (labelIndex === -1) {
|
|
7447
|
+
return null;
|
|
7448
|
+
}
|
|
7449
|
+
return lines[labelIndex + 1]?.trim() || null;
|
|
7450
|
+
}
|
|
7451
|
+
function parseLabeledTextValue(lines, label) {
|
|
7452
|
+
const expected = label.toLowerCase();
|
|
7453
|
+
const labelIndex = lines.findIndex((line) => line.toLowerCase() === expected);
|
|
7454
|
+
if (labelIndex !== -1) {
|
|
7455
|
+
return lines[labelIndex + 1]?.trim() || null;
|
|
7456
|
+
}
|
|
7457
|
+
const inline = lines.find((line) => line.toLowerCase().startsWith(`${expected} `));
|
|
7458
|
+
if (!inline) {
|
|
7459
|
+
return null;
|
|
7460
|
+
}
|
|
7461
|
+
return inline.slice(label.length).trim() || null;
|
|
7462
|
+
}
|
|
7463
|
+
function parseLinkedInCompactInteger(value) {
|
|
7464
|
+
if (!value) {
|
|
7465
|
+
return null;
|
|
7466
|
+
}
|
|
7467
|
+
const match = value.replace(/,/g, "").match(/([0-9]+(?:\.[0-9]+)?)\s*([kmb])?/i);
|
|
7468
|
+
if (!match) {
|
|
7469
|
+
return null;
|
|
7470
|
+
}
|
|
7471
|
+
const base = Number(match[1]);
|
|
7472
|
+
if (!Number.isFinite(base)) {
|
|
7473
|
+
return null;
|
|
7474
|
+
}
|
|
7475
|
+
const suffix = match[2]?.toLowerCase();
|
|
7476
|
+
const multiplier = suffix === "b" ? 1_000_000_000 : suffix === "m" ? 1_000_000 : suffix === "k" ? 1_000 : 1;
|
|
7477
|
+
return Math.round(base * multiplier);
|
|
7478
|
+
}
|
|
7479
|
+
function parseDealroomEnterpriseValueUsd(value) {
|
|
7480
|
+
if (!value) {
|
|
7481
|
+
return null;
|
|
7482
|
+
}
|
|
7483
|
+
const match = value.replace(/,/g, "").match(/([$€£])?\s*([0-9]+(?:\.[0-9]+)?)\s*([kmb])?/i);
|
|
7484
|
+
if (!match) {
|
|
7485
|
+
return null;
|
|
7486
|
+
}
|
|
7487
|
+
const base = Number(match[2]);
|
|
7488
|
+
if (!Number.isFinite(base)) {
|
|
7489
|
+
return null;
|
|
7490
|
+
}
|
|
7491
|
+
const suffix = match[3]?.toLowerCase();
|
|
7492
|
+
const multiplier = suffix === "b" ? 1_000_000_000 : suffix === "m" ? 1_000_000 : suffix === "k" ? 1_000 : 1;
|
|
7493
|
+
return Math.round(base * multiplier);
|
|
7494
|
+
}
|
|
7495
|
+
function parseLinkedInNumber(value) {
|
|
7496
|
+
if (!value) {
|
|
7497
|
+
return null;
|
|
7498
|
+
}
|
|
7499
|
+
const normalized = value.replace(/,/g, "").match(/[0-9]+(?:\.[0-9]+)?/);
|
|
7500
|
+
if (!normalized) {
|
|
7501
|
+
return null;
|
|
7502
|
+
}
|
|
7503
|
+
const parsed = Number(normalized[0]);
|
|
7504
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
7505
|
+
}
|
|
7506
|
+
function parseLinkedInGrowth(value) {
|
|
7507
|
+
if (!value) {
|
|
7508
|
+
return { value: null, label: null };
|
|
7509
|
+
}
|
|
7510
|
+
const label = value.replace(/^▲\s*/i, "").trim();
|
|
7511
|
+
if (!label || /^n\/a$/i.test(label)) {
|
|
7512
|
+
return { value: null, label: label || null };
|
|
7513
|
+
}
|
|
7514
|
+
const parsed = parseLinkedInNumber(label);
|
|
7515
|
+
return { value: parsed, label };
|
|
7516
|
+
}
|
|
7517
|
+
function parseLinkedInTenureYears(lines) {
|
|
7518
|
+
const tenureLine = lines.find((line) => /median employee tenure/i.test(line));
|
|
7519
|
+
return parseLinkedInNumber(tenureLine);
|
|
7520
|
+
}
|
|
7521
|
+
function isLikelyLinkedInGrowthToken(value) {
|
|
7522
|
+
if (!value) {
|
|
7523
|
+
return false;
|
|
7524
|
+
}
|
|
7525
|
+
return /^(?:▲\s*)?(?:[0-9][0-9,]*(?:\.[0-9]+)?%?\+?|N\/A)$/i.test(value.trim());
|
|
7526
|
+
}
|
|
7527
|
+
function parseLinkedInInsightRows(lines) {
|
|
7528
|
+
const insights = [];
|
|
7529
|
+
const jobOpeningsIndex = lines.findIndex((line) => /^Total job openings$/i.test(line));
|
|
7530
|
+
if (jobOpeningsIndex >= 0) {
|
|
7531
|
+
const totalValue = parseLinkedInNumber(lines[jobOpeningsIndex + 1]);
|
|
7532
|
+
const period = lines[jobOpeningsIndex + 2] || null;
|
|
7533
|
+
insights.push({
|
|
7534
|
+
insightType: "job_openings_total",
|
|
7535
|
+
label: "Total",
|
|
7536
|
+
period,
|
|
7537
|
+
metricValue: totalValue,
|
|
7538
|
+
metricUnit: "jobs",
|
|
7539
|
+
growth3mPercent: null,
|
|
7540
|
+
growth6mPercent: null,
|
|
7541
|
+
growth1yPercent: null,
|
|
7542
|
+
growth2yPercent: null,
|
|
7543
|
+
growth3mLabel: null,
|
|
7544
|
+
growth6mLabel: null,
|
|
7545
|
+
growth1yLabel: null,
|
|
7546
|
+
growth2yLabel: null,
|
|
7547
|
+
rawPayload: { source: "total_job_openings", lines: lines.slice(jobOpeningsIndex, jobOpeningsIndex + 12) },
|
|
7548
|
+
});
|
|
7549
|
+
const jobEndIndex = lines.findIndex((line, index) => index > jobOpeningsIndex && /^Total employee count$/i.test(line));
|
|
7550
|
+
const jobRows = lines.slice(jobOpeningsIndex + 3, jobEndIndex === -1 ? lines.length : jobEndIndex);
|
|
7551
|
+
for (let index = 0; index < jobRows.length - 2; index += 1) {
|
|
7552
|
+
const label = jobRows[index];
|
|
7553
|
+
const growth3m = jobRows[index + 1];
|
|
7554
|
+
const growth6m = jobRows[index + 2];
|
|
7555
|
+
if (!label || !isLikelyLinkedInGrowthToken(growth3m) || !isLikelyLinkedInGrowthToken(growth6m)) {
|
|
7556
|
+
continue;
|
|
7557
|
+
}
|
|
7558
|
+
const parsed3m = parseLinkedInGrowth(growth3m);
|
|
7559
|
+
const parsed6m = parseLinkedInGrowth(growth6m);
|
|
7560
|
+
insights.push({
|
|
7561
|
+
insightType: "job_openings_function",
|
|
7562
|
+
label,
|
|
7563
|
+
period,
|
|
7564
|
+
metricValue: null,
|
|
7565
|
+
metricUnit: "jobs",
|
|
7566
|
+
growth3mPercent: parsed3m.value,
|
|
7567
|
+
growth6mPercent: parsed6m.value,
|
|
7568
|
+
growth1yPercent: null,
|
|
7569
|
+
growth2yPercent: null,
|
|
7570
|
+
growth3mLabel: parsed3m.label,
|
|
7571
|
+
growth6mLabel: parsed6m.label,
|
|
7572
|
+
growth1yLabel: null,
|
|
7573
|
+
growth2yLabel: null,
|
|
7574
|
+
rawPayload: { source: "job_openings_function", label, growth3m, growth6m },
|
|
7575
|
+
});
|
|
7576
|
+
index += 2;
|
|
7577
|
+
}
|
|
7578
|
+
}
|
|
7579
|
+
const employeeCountIndex = lines.findIndex((line) => /^Total employee count$/i.test(line));
|
|
7580
|
+
if (employeeCountIndex >= 0) {
|
|
7581
|
+
const growth6m = parseLinkedInGrowth(lines[employeeCountIndex + 3]);
|
|
7582
|
+
const growth1y = parseLinkedInGrowth(lines[employeeCountIndex + 4]);
|
|
7583
|
+
const growth2y = parseLinkedInGrowth(lines[employeeCountIndex + 5]);
|
|
7584
|
+
insights.push({
|
|
7585
|
+
insightType: "employee_count_total",
|
|
7586
|
+
label: "Total",
|
|
7587
|
+
period: "current",
|
|
7588
|
+
metricValue: parseLinkedInNumber(lines[employeeCountIndex + 1]),
|
|
7589
|
+
metricUnit: "employees",
|
|
7590
|
+
growth3mPercent: null,
|
|
7591
|
+
growth6mPercent: growth6m.value,
|
|
7592
|
+
growth1yPercent: growth1y.value,
|
|
7593
|
+
growth2yPercent: growth2y.value,
|
|
7594
|
+
growth3mLabel: null,
|
|
7595
|
+
growth6mLabel: growth6m.label,
|
|
7596
|
+
growth1yLabel: growth1y.label,
|
|
7597
|
+
growth2yLabel: growth2y.label,
|
|
7598
|
+
rawPayload: { source: "total_employee_count", lines: lines.slice(employeeCountIndex, employeeCountIndex + 8) },
|
|
7599
|
+
});
|
|
7600
|
+
}
|
|
7601
|
+
const functionIndex = lines.findIndex((line) => /^Employee distribution and headcount growth by function$/i.test(line));
|
|
7602
|
+
if (functionIndex >= 0) {
|
|
7603
|
+
const period = lines[functionIndex + 2] || null;
|
|
7604
|
+
const functionEndIndex = lines.findIndex((line, index) => index > functionIndex && /^Select insights not available$/i.test(line));
|
|
7605
|
+
const functionRows = lines.slice(functionIndex + 3, functionEndIndex === -1 ? lines.length : functionEndIndex);
|
|
7606
|
+
for (let index = 0; index < functionRows.length - 2; index += 1) {
|
|
7607
|
+
const label = functionRows[index];
|
|
7608
|
+
const growth6m = functionRows[index + 1];
|
|
7609
|
+
const growth1y = functionRows[index + 2];
|
|
7610
|
+
if (!label || !isLikelyLinkedInGrowthToken(growth6m) || !isLikelyLinkedInGrowthToken(growth1y)) {
|
|
7611
|
+
continue;
|
|
7612
|
+
}
|
|
7613
|
+
const parsed6m = parseLinkedInGrowth(growth6m);
|
|
7614
|
+
const parsed1y = parseLinkedInGrowth(growth1y);
|
|
7615
|
+
insights.push({
|
|
7616
|
+
insightType: "employee_function_distribution",
|
|
7617
|
+
label,
|
|
7618
|
+
period,
|
|
7619
|
+
metricValue: null,
|
|
7620
|
+
metricUnit: "employees",
|
|
7621
|
+
growth3mPercent: null,
|
|
7622
|
+
growth6mPercent: parsed6m.value,
|
|
7623
|
+
growth1yPercent: parsed1y.value,
|
|
7624
|
+
growth2yPercent: null,
|
|
7625
|
+
growth3mLabel: null,
|
|
7626
|
+
growth6mLabel: parsed6m.label,
|
|
7627
|
+
growth1yLabel: parsed1y.label,
|
|
7628
|
+
growth2yLabel: null,
|
|
7629
|
+
rawPayload: { source: "employee_function_distribution", label, growth6m, growth1y },
|
|
7630
|
+
});
|
|
7631
|
+
index += 2;
|
|
7632
|
+
}
|
|
7633
|
+
}
|
|
7634
|
+
const unavailableIndex = lines.findIndex((line) => /^Select insights not available$/i.test(line));
|
|
7635
|
+
if (unavailableIndex >= 0) {
|
|
7636
|
+
const detail = lines[unavailableIndex + 1] || null;
|
|
7637
|
+
insights.push({
|
|
7638
|
+
insightType: "unavailable_insight",
|
|
7639
|
+
label: detail,
|
|
7640
|
+
period: null,
|
|
7641
|
+
metricValue: null,
|
|
7642
|
+
metricUnit: null,
|
|
7643
|
+
growth3mPercent: null,
|
|
7644
|
+
growth6mPercent: null,
|
|
7645
|
+
growth1yPercent: null,
|
|
7646
|
+
growth2yPercent: null,
|
|
7647
|
+
growth3mLabel: null,
|
|
7648
|
+
growth6mLabel: null,
|
|
7649
|
+
growth1yLabel: null,
|
|
7650
|
+
growth2yLabel: null,
|
|
7651
|
+
rawPayload: { source: "select_insights_not_available", detail },
|
|
7652
|
+
});
|
|
7653
|
+
}
|
|
7654
|
+
return insights;
|
|
7655
|
+
}
|
|
7656
|
+
function parseLocalLinkedInCompanyText(text, rawUrl, insightsText = "") {
|
|
7657
|
+
const urls = normalizeLinkedInCompanyScrapeUrl(rawUrl);
|
|
7658
|
+
const lines = text
|
|
7659
|
+
.split(/\r?\n/)
|
|
7660
|
+
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
7661
|
+
.filter(Boolean);
|
|
7662
|
+
const insightLines = insightsText
|
|
7663
|
+
.split(/\r?\n/)
|
|
7664
|
+
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
7665
|
+
.filter(Boolean);
|
|
7666
|
+
const website = parseLinkedInAboutTextValue(lines, "Website");
|
|
7667
|
+
const industry = parseLinkedInAboutTextValue(lines, "Industry");
|
|
7668
|
+
const companySize = parseLinkedInAboutTextValue(lines, "Company size");
|
|
7669
|
+
const associatedMembersText = lines.find((line) => /associated members\b/i.test(line)) ?? null;
|
|
7670
|
+
const founded = parseLinkedInCompactInteger(parseLinkedInAboutTextValue(lines, "Founded"));
|
|
7671
|
+
const headquarters = parseLinkedInAboutTextValue(lines, "Headquarters");
|
|
7672
|
+
const overviewIndex = lines.findIndex((line) => line.toLowerCase() === "overview");
|
|
7673
|
+
const description = overviewIndex >= 0
|
|
7674
|
+
? lines
|
|
7675
|
+
.slice(overviewIndex + 1)
|
|
7676
|
+
.find((line) => !["Website", "Industry", "Company size", "Founded", "Locations (1)", "Headquarters"].includes(line)) ?? null
|
|
7677
|
+
: null;
|
|
7678
|
+
const name = lines.find((line, index) => {
|
|
7679
|
+
if (index > 12) {
|
|
7680
|
+
return false;
|
|
7681
|
+
}
|
|
7682
|
+
return (!["Home", "About", "Posts", "Jobs", "People", "Insights", "Message", "Following", "Follow"].includes(line) &&
|
|
7683
|
+
!line.includes("followers") &&
|
|
7684
|
+
!line.includes("employees") &&
|
|
7685
|
+
!line.includes("connections"));
|
|
7686
|
+
}) ?? null;
|
|
7687
|
+
const insights = parseLinkedInInsightRows(insightLines);
|
|
7688
|
+
return {
|
|
7689
|
+
...urls,
|
|
7690
|
+
name,
|
|
7691
|
+
website,
|
|
7692
|
+
domain: normalizeLocalCompanyDomain(website),
|
|
7693
|
+
industry,
|
|
7694
|
+
companySize,
|
|
7695
|
+
employeesOnLinkedIn: parseLinkedInCompactInteger(associatedMembersText),
|
|
7696
|
+
totalEmployeeCount: parseLinkedInCompactInteger(associatedMembersText),
|
|
7697
|
+
followerCount: parseLinkedInCompactInteger(lines.find((line) => /followers\b/i.test(line))),
|
|
7698
|
+
headquarters,
|
|
7699
|
+
founded,
|
|
7700
|
+
description,
|
|
7701
|
+
medianEmployeeTenureYears: parseLinkedInTenureYears(insightLines),
|
|
7702
|
+
insights,
|
|
7703
|
+
rawPayload: {
|
|
7704
|
+
sourceTextLines: lines,
|
|
7705
|
+
insightsTextLines: insightLines,
|
|
7706
|
+
},
|
|
7707
|
+
timestamp: new Date().toISOString(),
|
|
7708
|
+
};
|
|
7709
|
+
}
|
|
7710
|
+
function parseLocalDealroomCompanyText(text, rawUrl) {
|
|
7711
|
+
const urls = normalizeDealroomCompanyScrapeUrl(rawUrl);
|
|
7712
|
+
const lines = text
|
|
7713
|
+
.split(/\r?\n/)
|
|
7714
|
+
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
7715
|
+
.filter(Boolean);
|
|
7716
|
+
const reservedTopLines = new Set([
|
|
7717
|
+
"dealroom.co",
|
|
7718
|
+
"Dashboard",
|
|
7719
|
+
"News",
|
|
7720
|
+
"Companies",
|
|
7721
|
+
"Stats & Insights",
|
|
7722
|
+
"Sectors",
|
|
7723
|
+
"Locations",
|
|
7724
|
+
"Investors",
|
|
7725
|
+
"Transactions",
|
|
7726
|
+
"Public Multiples",
|
|
7727
|
+
"People",
|
|
7728
|
+
"More organizations",
|
|
7729
|
+
"Deep Dives",
|
|
7730
|
+
"Knowledge base",
|
|
7731
|
+
"Login",
|
|
7732
|
+
"Book a Demo",
|
|
7733
|
+
"Save",
|
|
7734
|
+
"Overview",
|
|
7735
|
+
"Similar companies",
|
|
7736
|
+
"Investments (1)",
|
|
7737
|
+
"Job openings",
|
|
7738
|
+
]);
|
|
7739
|
+
const name = lines.find((line) => {
|
|
7740
|
+
if (reservedTopLines.has(line)) {
|
|
7741
|
+
return false;
|
|
7742
|
+
}
|
|
7743
|
+
if (/search for companies/i.test(line)) {
|
|
7744
|
+
return false;
|
|
7745
|
+
}
|
|
7746
|
+
return true;
|
|
7747
|
+
}) ?? null;
|
|
7748
|
+
const tagline = name ? lines[lines.indexOf(name) + 1] ?? null : null;
|
|
7749
|
+
const website = normalizeDealroomWebsite(parseLabeledTextValue(lines, "Website"));
|
|
7750
|
+
const recentDealText = parseLabeledTextValue(lines, "Recent deals");
|
|
7751
|
+
const signalLine = lines.find((line) => /^\d{1,3}$/.test(line) && Number(line) <= 100 && lines[lines.indexOf(line) + 1] === "Dealroom.co signal");
|
|
7752
|
+
const sector = lines.find((line) => /#\d+\)?$/i.test(line) && !/^\d+$/.test(line)) ?? null;
|
|
7753
|
+
const knownLabels = new Set([
|
|
7754
|
+
"HQ location",
|
|
7755
|
+
"Website",
|
|
7756
|
+
"Launch date",
|
|
7757
|
+
"Employees",
|
|
7758
|
+
"Enterprise value",
|
|
7759
|
+
"Company register number",
|
|
7760
|
+
"Jobs",
|
|
7761
|
+
"Recent deals",
|
|
7762
|
+
]);
|
|
7763
|
+
const nonTagValues = new Set();
|
|
7764
|
+
for (const label of knownLabels) {
|
|
7765
|
+
const value = parseLabeledTextValue(lines, label);
|
|
7766
|
+
if (value) {
|
|
7767
|
+
nonTagValues.add(value);
|
|
7768
|
+
}
|
|
7769
|
+
}
|
|
7770
|
+
const tagStartIndex = lines.findIndex((line) => /^B2B$/i.test(line) || /^\$?\s*saas$/i.test(line));
|
|
7771
|
+
const tagEndIndex = sector ? lines.indexOf(sector) : -1;
|
|
7772
|
+
const tags = tagStartIndex >= 0
|
|
7773
|
+
? lines
|
|
7774
|
+
.slice(tagStartIndex, tagEndIndex > tagStartIndex ? tagEndIndex : undefined)
|
|
7775
|
+
.filter((line) => !knownLabels.has(line) && !nonTagValues.has(line))
|
|
7776
|
+
: [];
|
|
7777
|
+
const socialLinks = {};
|
|
7778
|
+
for (const line of lines) {
|
|
7779
|
+
if (/^https?:\/\/(www\.)?linkedin\.com\//i.test(line)) {
|
|
7780
|
+
socialLinks.linkedin = line;
|
|
7781
|
+
}
|
|
7782
|
+
else if (/^https?:\/\/(www\.)?(x|twitter)\.com\//i.test(line)) {
|
|
7783
|
+
socialLinks.x = line;
|
|
7784
|
+
}
|
|
7785
|
+
}
|
|
7786
|
+
const enterpriseValueText = parseLabeledTextValue(lines, "Enterprise value");
|
|
7787
|
+
return {
|
|
7788
|
+
...urls,
|
|
7789
|
+
name,
|
|
7790
|
+
tagline,
|
|
7791
|
+
hqLocation: parseLabeledTextValue(lines, "HQ location"),
|
|
7792
|
+
website,
|
|
7793
|
+
domain: normalizeLocalCompanyDomain(website),
|
|
7794
|
+
launchDateText: parseLabeledTextValue(lines, "Launch date"),
|
|
7795
|
+
employeeRange: parseLabeledTextValue(lines, "Employees"),
|
|
7796
|
+
enterpriseValueText,
|
|
7797
|
+
enterpriseValueUsd: parseDealroomEnterpriseValueUsd(enterpriseValueText),
|
|
7798
|
+
companyRegisterNumber: parseLabeledTextValue(lines, "Company register number"),
|
|
7799
|
+
jobsText: parseLabeledTextValue(lines, "Jobs"),
|
|
7800
|
+
recentDeals: recentDealText ? [recentDealText] : [],
|
|
7801
|
+
tags,
|
|
7802
|
+
sector,
|
|
7803
|
+
signalScore: signalLine ? Number(signalLine) : null,
|
|
7804
|
+
socialLinks,
|
|
7805
|
+
rawPayload: { sourceTextLines: lines },
|
|
7806
|
+
timestamp: new Date().toISOString(),
|
|
7807
|
+
};
|
|
7808
|
+
}
|
|
7809
|
+
async function upsertLocalLinkedInCompanyScrape(row) {
|
|
7810
|
+
const databaseUrl = process.env.SALESPROMPTER_DATABASE_URL?.trim() ||
|
|
7811
|
+
process.env.DATABASE_URL?.trim() ||
|
|
7812
|
+
"";
|
|
7813
|
+
if (!databaseUrl) {
|
|
7814
|
+
throw new Error("Missing DATABASE_URL or SALESPROMPTER_DATABASE_URL for LinkedIn company scraper storage.");
|
|
7815
|
+
}
|
|
7816
|
+
const client = new pg.Client({
|
|
7817
|
+
connectionString: databaseUrl,
|
|
7818
|
+
ssl: { rejectUnauthorized: false },
|
|
7819
|
+
});
|
|
7820
|
+
await client.connect();
|
|
7821
|
+
let committed = false;
|
|
7822
|
+
try {
|
|
7823
|
+
await client.query("BEGIN");
|
|
7824
|
+
await client.query(`INSERT INTO public.linkedin_companies_processed (
|
|
7825
|
+
error, query, timestamp, name, description, follower_count, website, domain, industry,
|
|
7826
|
+
company_size, founded, company_address, main_company_id, linkedin_id, sales_navigator_link,
|
|
7827
|
+
employees_on_linked_in, company_name, total_employee_count, company_url, headquarters
|
|
7828
|
+
) VALUES (
|
|
7829
|
+
NULL, $1, $2, $3, $4, $5, $6, $7, $8,
|
|
7830
|
+
$9, $10, $11, $12, $13, $14,
|
|
7831
|
+
$15, $16, $17, $18, $19
|
|
7832
|
+
)`, [
|
|
7833
|
+
`linkedin.com/company/${row.id}`,
|
|
7834
|
+
row.timestamp,
|
|
7835
|
+
row.name,
|
|
7836
|
+
row.description,
|
|
7837
|
+
row.followerCount,
|
|
7838
|
+
row.website,
|
|
7839
|
+
row.domain,
|
|
7840
|
+
row.industry,
|
|
7841
|
+
row.companySize,
|
|
7842
|
+
row.founded,
|
|
7843
|
+
row.headquarters,
|
|
7844
|
+
row.id,
|
|
7845
|
+
String(row.id),
|
|
7846
|
+
row.salesNavigatorLink,
|
|
7847
|
+
row.employeesOnLinkedIn,
|
|
7848
|
+
row.name,
|
|
7849
|
+
row.totalEmployeeCount,
|
|
7850
|
+
row.companyUrl,
|
|
7851
|
+
row.headquarters,
|
|
7852
|
+
]);
|
|
7853
|
+
const snapshotResult = await client.query(`INSERT INTO public.linkedin_company_local_scrape_snapshots (
|
|
7854
|
+
linkedin_company_id, linkedin_company_url, sales_navigator_link, company_name,
|
|
7855
|
+
scrape_source, scraped_at, observed_at, overview_text, website, domain, industry,
|
|
7856
|
+
company_size, employees_on_linkedin, total_employee_count, follower_count,
|
|
7857
|
+
headquarters, founded, median_employee_tenure_years, raw_payload
|
|
7858
|
+
) VALUES (
|
|
7859
|
+
$1, $2, $3, $4,
|
|
7860
|
+
'linkedin_company_scraper_local', $5, $5, $6, $7, $8, $9,
|
|
7861
|
+
$10, $11, $12, $13,
|
|
7862
|
+
$14, $15, $16, $17::jsonb
|
|
7863
|
+
)
|
|
7864
|
+
RETURNING id`, [
|
|
7865
|
+
row.id,
|
|
7866
|
+
row.companyUrl,
|
|
7867
|
+
row.salesNavigatorLink,
|
|
7868
|
+
row.name,
|
|
7869
|
+
row.timestamp,
|
|
7870
|
+
row.description,
|
|
7871
|
+
row.website,
|
|
7872
|
+
row.domain,
|
|
7873
|
+
row.industry,
|
|
7874
|
+
row.companySize,
|
|
7875
|
+
row.employeesOnLinkedIn,
|
|
7876
|
+
row.totalEmployeeCount,
|
|
7877
|
+
row.followerCount,
|
|
7878
|
+
row.headquarters,
|
|
7879
|
+
row.founded,
|
|
7880
|
+
row.medianEmployeeTenureYears,
|
|
7881
|
+
JSON.stringify(sanitizeForPostgresJson(row.rawPayload)),
|
|
7882
|
+
]);
|
|
7883
|
+
const snapshotId = snapshotResult.rows[0]?.id;
|
|
7884
|
+
if (!snapshotId) {
|
|
7885
|
+
throw new Error("Failed to create LinkedIn company local scrape snapshot.");
|
|
7886
|
+
}
|
|
7887
|
+
for (const insight of row.insights) {
|
|
7888
|
+
await client.query(`INSERT INTO public.linkedin_company_local_scrape_insights (
|
|
7889
|
+
snapshot_id, linkedin_company_id, insight_type, label, period, metric_value,
|
|
7890
|
+
metric_unit, growth_3m_percent, growth_6m_percent, growth_1y_percent,
|
|
7891
|
+
growth_2y_percent, growth_3m_label, growth_6m_label, growth_1y_label,
|
|
7892
|
+
growth_2y_label, raw_payload, scraped_at
|
|
7893
|
+
) VALUES (
|
|
7894
|
+
$1, $2, $3, $4, $5, $6,
|
|
7895
|
+
$7, $8, $9, $10,
|
|
7896
|
+
$11, $12, $13, $14,
|
|
7897
|
+
$15, $16::jsonb, $17
|
|
7898
|
+
)`, [
|
|
7899
|
+
snapshotId,
|
|
7900
|
+
row.id,
|
|
7901
|
+
insight.insightType,
|
|
7902
|
+
insight.label,
|
|
7903
|
+
insight.period,
|
|
7904
|
+
insight.metricValue,
|
|
7905
|
+
insight.metricUnit,
|
|
7906
|
+
insight.growth3mPercent,
|
|
7907
|
+
insight.growth6mPercent,
|
|
7908
|
+
insight.growth1yPercent,
|
|
7909
|
+
insight.growth2yPercent,
|
|
7910
|
+
insight.growth3mLabel,
|
|
7911
|
+
insight.growth6mLabel,
|
|
7912
|
+
insight.growth1yLabel,
|
|
7913
|
+
insight.growth2yLabel,
|
|
7914
|
+
JSON.stringify(sanitizeForPostgresJson(insight.rawPayload)),
|
|
7915
|
+
row.timestamp,
|
|
7916
|
+
]);
|
|
7917
|
+
}
|
|
7918
|
+
await client.query("COMMIT");
|
|
7919
|
+
committed = true;
|
|
7920
|
+
}
|
|
7921
|
+
finally {
|
|
7922
|
+
if (!committed) {
|
|
7923
|
+
try {
|
|
7924
|
+
await client.query("ROLLBACK");
|
|
7925
|
+
}
|
|
7926
|
+
catch {
|
|
7927
|
+
// The transaction may already be closed by the database.
|
|
7928
|
+
}
|
|
7929
|
+
}
|
|
7930
|
+
await client.end();
|
|
7931
|
+
}
|
|
7932
|
+
}
|
|
7933
|
+
async function insertLocalDealroomCompanyScrape(row) {
|
|
7934
|
+
const databaseUrl = process.env.SALESPROMPTER_DATABASE_URL?.trim() ||
|
|
7935
|
+
process.env.DATABASE_URL?.trim() ||
|
|
7936
|
+
"";
|
|
7937
|
+
if (!databaseUrl) {
|
|
7938
|
+
throw new Error("Missing DATABASE_URL or SALESPROMPTER_DATABASE_URL for Dealroom company scraper storage.");
|
|
7939
|
+
}
|
|
7940
|
+
const client = new pg.Client({
|
|
7941
|
+
connectionString: databaseUrl,
|
|
7942
|
+
ssl: { rejectUnauthorized: false },
|
|
7943
|
+
});
|
|
7944
|
+
await client.connect();
|
|
7945
|
+
let committed = false;
|
|
7946
|
+
try {
|
|
7947
|
+
await client.query("BEGIN");
|
|
7948
|
+
await client.query(`INSERT INTO public.dealroom_company_local_scrape_snapshots (
|
|
7949
|
+
dealroom_company_slug, dealroom_company_url, source_url, scrape_source,
|
|
7950
|
+
scraped_at, observed_at, company_name, tagline, hq_location, website,
|
|
7951
|
+
domain, launch_date_text, employee_range, enterprise_value_text,
|
|
7952
|
+
enterprise_value_usd, company_register_number, jobs_text, recent_deals,
|
|
7953
|
+
tags, sector, signal_score, social_links, raw_payload
|
|
7954
|
+
) VALUES (
|
|
7955
|
+
$1, $2, $3, 'dealroom_company_scraper_local',
|
|
7956
|
+
$4, $4, $5, $6, $7, $8,
|
|
7957
|
+
$9, $10, $11, $12,
|
|
7958
|
+
$13, $14, $15, $16,
|
|
7959
|
+
$17, $18, $19, $20::jsonb, $21::jsonb
|
|
7960
|
+
)`, [
|
|
7961
|
+
row.slug,
|
|
7962
|
+
row.companyUrl,
|
|
7963
|
+
row.sourceUrl,
|
|
7964
|
+
row.timestamp,
|
|
7965
|
+
row.name,
|
|
7966
|
+
row.tagline,
|
|
7967
|
+
row.hqLocation,
|
|
7968
|
+
row.website,
|
|
7969
|
+
row.domain,
|
|
7970
|
+
row.launchDateText,
|
|
7971
|
+
row.employeeRange,
|
|
7972
|
+
row.enterpriseValueText,
|
|
7973
|
+
row.enterpriseValueUsd,
|
|
7974
|
+
row.companyRegisterNumber,
|
|
7975
|
+
row.jobsText,
|
|
7976
|
+
row.recentDeals,
|
|
7977
|
+
row.tags,
|
|
7978
|
+
row.sector,
|
|
7979
|
+
row.signalScore,
|
|
7980
|
+
JSON.stringify(sanitizeForPostgresJson(row.socialLinks)),
|
|
7981
|
+
JSON.stringify(sanitizeForPostgresJson(row.rawPayload)),
|
|
7982
|
+
]);
|
|
7983
|
+
await client.query("COMMIT");
|
|
7984
|
+
committed = true;
|
|
7985
|
+
}
|
|
7986
|
+
finally {
|
|
7987
|
+
if (!committed) {
|
|
7988
|
+
try {
|
|
7989
|
+
await client.query("ROLLBACK");
|
|
7990
|
+
}
|
|
7991
|
+
catch {
|
|
7992
|
+
// The transaction may already be closed by the database.
|
|
7993
|
+
}
|
|
7994
|
+
}
|
|
7995
|
+
await client.end();
|
|
7996
|
+
}
|
|
7997
|
+
}
|
|
7368
7998
|
async function upsertSalesNavigatorAccounts(params) {
|
|
7369
7999
|
if (params.accounts.length === 0) {
|
|
7370
8000
|
return 0;
|
|
@@ -10599,6 +11229,10 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
|
10599
11229
|
commandName === "accounts:local-import" ||
|
|
10600
11230
|
commandName === "salesnav:accounts:repair" ||
|
|
10601
11231
|
commandName === "accounts:repair" ||
|
|
11232
|
+
commandName === "linkedin-companies:scrape-local" ||
|
|
11233
|
+
commandName === "companies:scrape-linkedin" ||
|
|
11234
|
+
commandName === "dealroom-companies:scrape-local" ||
|
|
11235
|
+
commandName === "companies:scrape-dealroom" ||
|
|
10602
11236
|
commandName.startsWith("packs:") ||
|
|
10603
11237
|
((commandName === "list" || commandName === "add") && parentCommandName === "packs")) {
|
|
10604
11238
|
return;
|
|
@@ -10945,6 +11579,76 @@ program
|
|
|
10945
11579
|
mergeSql: result.mergeSql
|
|
10946
11580
|
});
|
|
10947
11581
|
});
|
|
11582
|
+
program
|
|
11583
|
+
.command("linkedin-companies:scrape-local")
|
|
11584
|
+
.alias("companies:scrape-linkedin")
|
|
11585
|
+
.description("LinkedIn Company Scraper: parse a locally opened LinkedIn company page and store it in Salesprompter.")
|
|
11586
|
+
.requiredOption("--url <url>", "LinkedIn company URL with a numeric company id")
|
|
11587
|
+
.option("--text-file <path>", "Path to text copied from the LinkedIn company About page")
|
|
11588
|
+
.option("--insights-text-file <path>", "Path to text copied from the LinkedIn company Insights page")
|
|
11589
|
+
.option("--apply", "Store the scraped company profile in the Salesprompter database", false)
|
|
11590
|
+
.option("--no-open", "Do not open the LinkedIn company URL in the local browser")
|
|
11591
|
+
.action(async (options) => {
|
|
11592
|
+
const target = normalizeLinkedInCompanyScrapeUrl(options.url);
|
|
11593
|
+
if (options.open !== false) {
|
|
11594
|
+
await new Promise((resolve) => {
|
|
11595
|
+
const child = spawn("open", [target.companyUrl], { stdio: "ignore" });
|
|
11596
|
+
child.on("close", () => resolve());
|
|
11597
|
+
child.on("error", () => resolve());
|
|
11598
|
+
});
|
|
11599
|
+
}
|
|
11600
|
+
const inputText = options.textFile
|
|
11601
|
+
? await readFile(path.resolve(String(options.textFile)), "utf8")
|
|
11602
|
+
: await readAllStdin();
|
|
11603
|
+
if (!inputText.trim()) {
|
|
11604
|
+
throw new Error("No LinkedIn company page text provided. Open the company page, copy the About page text, then pass --text-file or pipe it on stdin.");
|
|
11605
|
+
}
|
|
11606
|
+
const insightsText = options.insightsTextFile
|
|
11607
|
+
? await readFile(path.resolve(String(options.insightsTextFile)), "utf8")
|
|
11608
|
+
: "";
|
|
11609
|
+
const profile = parseLocalLinkedInCompanyText(inputText, target.sourceUrl, insightsText);
|
|
11610
|
+
if (options.apply) {
|
|
11611
|
+
await upsertLocalLinkedInCompanyScrape(profile);
|
|
11612
|
+
}
|
|
11613
|
+
printOutput({
|
|
11614
|
+
status: "ok",
|
|
11615
|
+
stored: Boolean(options.apply),
|
|
11616
|
+
profile,
|
|
11617
|
+
});
|
|
11618
|
+
});
|
|
11619
|
+
program
|
|
11620
|
+
.command("dealroom-companies:scrape-local")
|
|
11621
|
+
.alias("companies:scrape-dealroom")
|
|
11622
|
+
.description("Dealroom Company Scraper: parse a locally opened Dealroom company page and store it in Salesprompter.")
|
|
11623
|
+
.requiredOption("--url <url>", "Dealroom company URL with /companies/<slug>")
|
|
11624
|
+
.option("--text-file <path>", "Path to text copied from the Dealroom company page")
|
|
11625
|
+
.option("--apply", "Store the scraped Dealroom company profile in the Salesprompter database", false)
|
|
11626
|
+
.option("--no-open", "Do not open the Dealroom company URL in the local browser")
|
|
11627
|
+
.action(async (options) => {
|
|
11628
|
+
const target = normalizeDealroomCompanyScrapeUrl(options.url);
|
|
11629
|
+
if (options.open !== false) {
|
|
11630
|
+
await new Promise((resolve) => {
|
|
11631
|
+
const child = spawn("open", [target.companyUrl], { stdio: "ignore" });
|
|
11632
|
+
child.on("close", () => resolve());
|
|
11633
|
+
child.on("error", () => resolve());
|
|
11634
|
+
});
|
|
11635
|
+
}
|
|
11636
|
+
const inputText = options.textFile
|
|
11637
|
+
? await readFile(path.resolve(String(options.textFile)), "utf8")
|
|
11638
|
+
: await readAllStdin();
|
|
11639
|
+
if (!inputText.trim()) {
|
|
11640
|
+
throw new Error("No Dealroom company page text provided. Open the company page, copy the visible profile text, then pass --text-file or pipe it on stdin.");
|
|
11641
|
+
}
|
|
11642
|
+
const profile = parseLocalDealroomCompanyText(inputText, target.sourceUrl);
|
|
11643
|
+
if (options.apply) {
|
|
11644
|
+
await insertLocalDealroomCompanyScrape(profile);
|
|
11645
|
+
}
|
|
11646
|
+
printOutput({
|
|
11647
|
+
status: "ok",
|
|
11648
|
+
stored: Boolean(options.apply),
|
|
11649
|
+
profile,
|
|
11650
|
+
});
|
|
11651
|
+
});
|
|
10948
11652
|
program
|
|
10949
11653
|
.command("linkedin-products:scrape")
|
|
10950
11654
|
.alias("market:scrape")
|
package/package.json
CHANGED