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.
Files changed (2) hide show
  1. package/dist/cli.js +704 -0
  2. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
4
  "description": "Sales workflow CLI for guided lead generation, enrichment, scoring, and sync.",
5
5
  "author": "Daniel Sinewe <hello@danielsinewe.com>",
6
6
  "type": "module",