linkedin-studio/scripts/analytics/src/cli.ts
Kjell Tore Guttormsen e029841800 feat(linkedin-studio): S16 — optional manual saves in analytics + close deferred onboarding Write MAJOR
Lifts the original v4.0.0 Non-Goal: an optional, manually-entered `saves`
metric through the analytics layer, built location-agnostic (option c) so
UI-brief §9b/M0 relocates the data dir in one place later.

- types: PostMetrics.saves? + Weekly/Monthly summary.totalSaves? (optional);
  new RankableMetric type for the always-numeric index-access whitelist
- parser: dedicated parseOptionalCount() — blank/non-numeric/negative -> undefined
  ("unknown != 0"), genuine 0 kept; saves NOT folded into engagementRate
- reports: totalSaves set only when >=1 post carries saves (backward-compat)
- cli: saves surfaced in import summary + weekly/monthly totals + per-post
- S16-pre: onboarding.md allowed-tools gains Write (closes S15-deferred MAJOR)
- docs (three-doc rule): plugin README boundary + analytics README + root README
  + plugin CLAUDE.md + CHANGELOG; dwell stays explicitly unmeasurable

Independent /trekreview: brief-conformance 0 findings; code-correctness 2 MAJOR
(own lockstep misses) FIXED in-session (parseOptionalCount + edge tests). Gate:
tsc clean, analytics 116/116, lint 74/0/0, hooks 98/98. Within-v4.1.0 refinement
(no surface/count/version change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:23:12 +02:00

469 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { parseLinkedInCSV } from "./parsers/csv-parser.js";
import {
getAnalyticsRoot,
ensureDirectories,
saveBatch,
loadAllPosts,
} from "./utils/storage.js";
import { detectAlerts } from "./utils/alerts.js";
import { mean, standardDeviation } from "./utils/stats.js";
import { generateWeeklyReport, getCurrentISOWeek } from "./reports/weekly.js";
import { generateHeatmap } from "./reports/heatmap.js";
import { generateMonthlyReport } from "./reports/monthly.js";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { RankableMetric } from "./models/types.js";
const args = process.argv.slice(2);
const command = args[0];
function parseOption(args: string[], flag: string): string | undefined {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
}
/**
* Per-post saves suffix for report lines. Empty string when the post carries no
* manual saves data, so saves-free output stays identical to the pre-saves CLI.
*/
function savesSuffix(saves?: number): string {
return saves !== undefined ? ` | ${saves.toLocaleString()} saves` : "";
}
function printUsage() {
console.log(`
LinkedIn Analytics CLI
Usage:
node --import tsx src/cli.ts import <filename> Import a CSV export
node --import tsx src/cli.ts report [--week W] Generate weekly report
node --import tsx src/cli.ts report --month YYYY-MM Generate monthly report with MoM comparison
node --import tsx src/cli.ts trends [--period P] [--metric M] Show trends and alerts
node --import tsx src/cli.ts heatmap Day-of-week performance matrix
Options:
--week W ISO week (e.g., 2026-W05), defaults to current week
--period P Time period: "week" | "month" | "quarter" | "all" (default: "month")
--metric M Metric to analyze: "impressions" | "reactions" | "comments" | "shares" | "clicks" | "engagementRate" (default: "impressions")
Examples:
node --import tsx src/cli.ts import linkedin-export-2026-01-20.csv
node --import tsx src/cli.ts report --week 2026-W04
node --import tsx src/cli.ts trends --period quarter --metric engagementRate
`);
}
async function handleImport(root: string, args: string[]) {
const filename = args[1];
if (!filename) {
console.error("Error: Missing filename argument");
console.error("Usage: node --import tsx src/cli.ts import <filename>");
process.exit(1);
}
const fullPath = join(root, "exports", filename);
if (!existsSync(fullPath)) {
console.error(`Error: File not found: ${fullPath}`);
console.error(`\nMake sure the CSV file is placed in: ${join(root, "exports")}`);
process.exit(1);
}
console.log(`Importing ${filename}...`);
try {
const batch = parseLinkedInCSV(fullPath, filename);
const savedFilename = saveBatch(root, batch);
console.log("\nImport successful!");
console.log("─────────────────────────────────────");
console.log(`Posts imported: ${batch.postCount}`);
console.log(`Date range: ${batch.dateRange.from} to ${batch.dateRange.to}`);
console.log(`Batch ID: ${batch.batchId}`);
console.log(`Saved to: posts/${savedFilename}`);
// Surface manually-entered saves when the CSV carried a Saves column.
const savesPosts = batch.posts.filter((p) => p.metrics.saves !== undefined);
if (savesPosts.length > 0) {
const totalSaves = savesPosts.reduce((sum, p) => sum + (p.metrics.saves ?? 0), 0);
console.log(`Saves entered: ${totalSaves.toLocaleString()} across ${savesPosts.length} post(s) (manual)`);
}
// Run alert detection on imported posts
const alerts = detectAlerts(batch.posts, "impressions");
if (alerts.length > 0) {
console.log("\nImmediate alerts detected:");
console.log("─────────────────────────────────────");
for (const alert of alerts.slice(0, 5)) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
if (alerts.length > 5) {
console.log(`\n... and ${alerts.length - 5} more alerts`);
}
} else {
console.log("\nNo anomalies detected in imported data.");
}
} catch (err) {
console.error(`Error parsing CSV: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function handleReport(root: string, args: string[]) {
const monthOption = parseOption(args, "--month");
if (monthOption) {
return handleMonthlyReport(root, monthOption);
}
const weekOption = parseOption(args, "--week");
const week = weekOption || getCurrentISOWeek();
console.log(`Generating weekly report for ${week}...`);
try {
const report = generateWeeklyReport(root, week);
console.log("\nWeekly Report");
console.log("═════════════════════════════════════");
console.log(`Week: ${report.week}`);
console.log(`Generated at: ${new Date(report.generatedAt).toLocaleString()}`);
console.log();
console.log("Summary");
console.log("─────────────────────────────────────");
console.log(`Total posts: ${report.summary.totalPosts}`);
console.log(`Total impressions: ${report.summary.totalImpressions.toLocaleString()}`);
console.log(`Total reactions: ${report.summary.totalReactions.toLocaleString()}`);
console.log(`Total comments: ${report.summary.totalComments.toLocaleString()}`);
console.log(`Total shares: ${report.summary.totalShares.toLocaleString()}`);
console.log(`Total clicks: ${report.summary.totalClicks.toLocaleString()}`);
if (report.summary.totalSaves !== undefined) {
console.log(`Total saves: ${report.summary.totalSaves.toLocaleString()} (manual entry — top engagement signal)`);
}
console.log(`Avg engagement: ${report.summary.avgEngagementRate.toFixed(2)}%`);
console.log(`Avg impressions: ${Math.round(report.summary.avgImpressionsPerPost).toLocaleString()} per post`);
console.log();
if (report.topPerformers.length > 0) {
console.log("Top Performers");
console.log("─────────────────────────────────────");
for (const post of report.topPerformers.slice(0, 5)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`);
}
console.log();
}
if (report.underperformers.length > 0) {
console.log("Underperformers");
console.log("─────────────────────────────────────");
for (const post of report.underperformers.slice(0, 3)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`);
}
console.log();
}
console.log("Trends");
console.log("─────────────────────────────────────");
console.log(`Impressions trend: ${report.trends.impressionsTrend.toUpperCase()} (${report.trends.percentChange.impressions > 0 ? "+" : ""}${report.trends.percentChange.impressions.toFixed(1)}%)`);
console.log(`Engagement trend: ${report.trends.engagementTrend.toUpperCase()} (${report.trends.percentChange.engagement > 0 ? "+" : ""}${report.trends.percentChange.engagement.toFixed(1)}%)`);
console.log(`Compared to: ${report.trends.comparedTo}`);
console.log();
if (report.alerts.length > 0) {
console.log("Alerts");
console.log("─────────────────────────────────────");
for (const alert of report.alerts) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
console.log();
}
console.log(`Report saved to: weekly-reports/${week}.json`);
} catch (err) {
console.error(`Error generating report: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
/**
* Type guard to check if a string is a rankable (always-numeric) metric key.
* Excludes the optional, manually-entered `saves` — it is not a trend metric.
*/
function isPostMetric(value: string): value is RankableMetric {
const validMetrics: RankableMetric[] = [
"impressions",
"reactions",
"comments",
"shares",
"clicks",
"engagementRate",
];
return validMetrics.includes(value as RankableMetric);
}
async function handleTrends(root: string, args: string[]) {
const periodOption = parseOption(args, "--period") || "month";
const metricOption = parseOption(args, "--metric") || "impressions";
const validPeriods = ["week", "month", "quarter", "all"];
if (!validPeriods.includes(periodOption)) {
console.error(`Error: Invalid period "${periodOption}". Must be one of: ${validPeriods.join(", ")}`);
process.exit(1);
}
if (!isPostMetric(metricOption)) {
const validMetrics: RankableMetric[] = [
"impressions",
"reactions",
"comments",
"shares",
"clicks",
"engagementRate",
];
console.error(`Error: Invalid metric "${metricOption}". Must be one of: ${validMetrics.join(", ")}`);
process.exit(1);
}
const period = periodOption as "week" | "month" | "quarter" | "all";
const metric = metricOption;
console.log(`Analyzing trends for ${metric} over ${period}...`);
try {
const allPosts = loadAllPosts(root);
if (allPosts.length === 0) {
console.error("Error: No posts found. Import some data first.");
process.exit(1);
}
// Calculate date range based on period
const now = new Date();
let fromDate = new Date(0); // Beginning of time for "all"
if (period === "week") {
fromDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
} else if (period === "month") {
fromDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
} else if (period === "quarter") {
fromDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
}
const fromDateStr = fromDate.toISOString().split("T")[0];
// Filter posts by period
const filteredPosts = allPosts.filter(
(post) => post.publishedDate >= fromDateStr
);
if (filteredPosts.length === 0) {
console.error(`Error: No posts found in the ${period} period.`);
process.exit(1);
}
// Calculate statistics
const values = filteredPosts.map((post) => post.metrics[metric]);
const avg = mean(values);
const stdDev = standardDeviation(values);
const min = Math.min(...values);
const max = Math.max(...values);
console.log("\nTrend Analysis");
console.log("═════════════════════════════════════");
console.log(`Metric: ${metric}`);
console.log(`Period: ${period}`);
console.log(`Posts analyzed: ${filteredPosts.length}`);
console.log(`Date range: ${filteredPosts[filteredPosts.length - 1].publishedDate} to ${filteredPosts[0].publishedDate}`);
console.log();
console.log("Statistics");
console.log("─────────────────────────────────────");
console.log(`Mean: ${avg.toFixed(2)}`);
console.log(`Std deviation: ${stdDev.toFixed(2)}`);
console.log(`Min: ${min.toFixed(2)}`);
console.log(`Max: ${max.toFixed(2)}`);
console.log();
// Generate alerts
const alerts = detectAlerts(filteredPosts, metric);
if (alerts.length > 0) {
console.log("Alerts");
console.log("─────────────────────────────────────");
for (const alert of alerts) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
} else {
console.log("No anomalies detected in this period.");
}
} catch (err) {
console.error(`Error analyzing trends: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function handleMonthlyReport(root: string, month: string) {
console.log(`Generating monthly report for ${month}...`);
try {
const report = generateMonthlyReport(root, month);
console.log("\nMonthly Report");
console.log("═════════════════════════════════════");
console.log(`Month: ${report.month}`);
console.log(`Generated at: ${new Date(report.generatedAt).toLocaleString()}`);
console.log();
console.log("Summary");
console.log("─────────────────────────────────────");
const s = report.summary;
const fmtDelta = (val: number | null, suffix = "%") =>
val !== null ? ` (${val > 0 ? "+" : ""}${val}${suffix})` : "";
console.log(`Posts: ${s.totalPosts}${fmtDelta(report.trends.percentChange.postCount)}`);
console.log(`Impressions: ${s.totalImpressions.toLocaleString()}${fmtDelta(report.trends.percentChange.impressions)}`);
console.log(`Avg per post: ${s.avgImpressionsPerPost.toLocaleString()}`);
console.log(`Avg engagement: ${s.avgEngagementRate.toFixed(2)}%${fmtDelta(report.trends.percentChange.engagement)}`);
console.log(`Reactions: ${s.totalReactions.toLocaleString()}`);
console.log(`Comments: ${s.totalComments.toLocaleString()}`);
console.log(`Shares: ${s.totalShares.toLocaleString()}`);
console.log(`Clicks: ${s.totalClicks.toLocaleString()}`);
if (s.totalSaves !== undefined) {
console.log(`Saves: ${s.totalSaves.toLocaleString()} (manual entry — top engagement signal)`);
}
console.log();
if (report.byWeek.length > 0) {
console.log("Week Breakdown");
console.log("─────────────────────────────────────");
for (const w of report.byWeek) {
console.log(`${w.week}: ${w.postCount} posts | ${w.avgImpressions.toLocaleString()} avg impr | ${w.avgEngagementRate.toFixed(1)}% eng`);
}
console.log();
}
if (report.topPerformers.length > 0) {
console.log("Top Performers");
console.log("─────────────────────────────────────");
for (const post of report.topPerformers.slice(0, 5)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`);
}
console.log();
}
if (report.trends.comparedTo) {
console.log(`Compared to: ${report.trends.comparedTo}`);
} else {
console.log("No previous month data for comparison.");
}
console.log();
if (report.alerts.length > 0) {
console.log("Alerts");
console.log("─────────────────────────────────────");
for (const alert of report.alerts) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
console.log();
}
console.log(`Report saved to: monthly-reports/${month}.json`);
} catch (err) {
console.error(`Error generating monthly report: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function handleHeatmap(root: string) {
console.log("Generating day-of-week heatmap...");
try {
const allPosts = loadAllPosts(root);
if (allPosts.length === 0) {
console.error("Error: No posts found. Import some data first.");
process.exit(1);
}
const report = generateHeatmap(allPosts);
console.log("\nDay-of-Week Performance Heatmap");
console.log("═════════════════════════════════════");
console.log(`Posts analyzed: ${report.postsAnalyzed}`);
console.log(`Date range: ${report.dateRange.from} to ${report.dateRange.to}`);
console.log();
// Print table header
const days = report.byDayOfWeek.map(d => d.dayName.slice(0, 3).padStart(7));
console.log(` ${days.join("")}`);
console.log(` ${"───────".repeat(7)}`);
// Posts row
const postCounts = report.byDayOfWeek.map(d => String(d.postCount).padStart(7));
console.log(`Posts: ${postCounts.join("")}`);
// Impressions row
const impressions = report.byDayOfWeek.map(d =>
d.postCount > 0 ? d.avgImpressions.toLocaleString().padStart(7) : " -"
);
console.log(`Impr: ${impressions.join("")}`);
// Engagement rate row
const engRates = report.byDayOfWeek.map(d =>
d.postCount > 0 ? `${d.avgEngagementRate.toFixed(1)}%`.padStart(7) : " -"
);
console.log(`Eng: ${engRates.join("")}`);
console.log();
console.log(`Best day for impressions: ${report.bestDayImpressions}`);
console.log(`Best day for engagement: ${report.bestDayEngagement}`);
console.log("\nNote: LinkedIn CSV exports do not include publish time.");
console.log("This heatmap shows day-of-week only.");
} catch (err) {
console.error(`Error generating heatmap: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function main() {
const root = getAnalyticsRoot();
ensureDirectories(root);
switch (command) {
case "import":
await handleImport(root, args);
break;
case "report":
await handleReport(root, args);
break;
case "trends":
await handleTrends(root, args);
break;
case "heatmap":
await handleHeatmap(root);
break;
default:
printUsage();
process.exit(command ? 1 : 0);
}
}
main().catch((err) => {
console.error("Fatal error:", err instanceof Error ? err.message : String(err));
process.exit(1);
});