feat(linkedin-thought-leadership): v1.1.0 — Q2 2026 feature release
9 improvements across 3 tracks: Onboarding: /linkedin:onboarding wizard, README Quick Start rewrite Content Quality: voice drift scoring, industry angle variants, /linkedin:carousel, /linkedin:react multi-URL comparison Analytics: automated week-rollover, day-of-week heatmap, month-over-month reports 25→27 commands. All Q2 ROADMAP items completed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
abf7322200
commit
1a8cc1942c
33 changed files with 1726 additions and 236 deletions
|
|
@ -8,6 +8,8 @@ import {
|
|||
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 { PostMetrics } from "./models/types.js";
|
||||
|
|
@ -27,7 +29,9 @@ LinkedIn Analytics CLI
|
|||
Usage:
|
||||
node build/cli.js import <filename> Import a CSV export
|
||||
node build/cli.js report [--week W] Generate weekly report
|
||||
node build/cli.js report --month YYYY-MM Generate monthly report with MoM comparison
|
||||
node build/cli.js trends [--period P] [--metric M] Show trends and alerts
|
||||
node build/cli.js heatmap Day-of-week performance matrix
|
||||
|
||||
Options:
|
||||
--week W ISO week (e.g., 2026-W05), defaults to current week
|
||||
|
|
@ -95,6 +99,11 @@ async function handleImport(root: string, args: string[]) {
|
|||
}
|
||||
|
||||
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();
|
||||
|
||||
|
|
@ -285,6 +294,130 @@ async function handleTrends(root: string, args: string[]) {
|
|||
}
|
||||
}
|
||||
|
||||
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()}`);
|
||||
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 | ${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);
|
||||
|
|
@ -299,6 +432,9 @@ async function main() {
|
|||
case "trends":
|
||||
await handleTrends(root, args);
|
||||
break;
|
||||
case "heatmap":
|
||||
await handleHeatmap(root);
|
||||
break;
|
||||
default:
|
||||
printUsage();
|
||||
process.exit(command ? 1 : 0);
|
||||
|
|
|
|||
|
|
@ -65,6 +65,55 @@ export interface Alert {
|
|||
deviations: number;
|
||||
}
|
||||
|
||||
export interface DayOfWeekMetrics {
|
||||
dayName: string; // "Monday" through "Sunday"
|
||||
dayIndex: number; // 1=Monday, 7=Sunday (ISO weekday)
|
||||
postCount: number;
|
||||
avgImpressions: number;
|
||||
avgEngagementRate: number;
|
||||
bestPost?: PostAnalytics;
|
||||
}
|
||||
|
||||
export interface HeatmapReport {
|
||||
generatedAt: string;
|
||||
postsAnalyzed: number;
|
||||
dateRange: { from: string; to: string };
|
||||
byDayOfWeek: DayOfWeekMetrics[]; // 7 entries, Mon-Sun ordered
|
||||
bestDayImpressions: string;
|
||||
bestDayEngagement: string;
|
||||
}
|
||||
|
||||
export interface MonthlyReport {
|
||||
month: string; // "YYYY-MM"
|
||||
generatedAt: string;
|
||||
summary: {
|
||||
totalPosts: number;
|
||||
totalImpressions: number;
|
||||
totalReactions: number;
|
||||
totalComments: number;
|
||||
totalShares: number;
|
||||
totalClicks: number;
|
||||
avgEngagementRate: number;
|
||||
avgImpressionsPerPost: number;
|
||||
};
|
||||
topPerformers: PostAnalytics[];
|
||||
byWeek: {
|
||||
week: string;
|
||||
postCount: number;
|
||||
avgImpressions: number;
|
||||
avgEngagementRate: number;
|
||||
}[];
|
||||
trends: {
|
||||
comparedTo: string | null;
|
||||
percentChange: {
|
||||
impressions: number | null;
|
||||
engagement: number | null;
|
||||
postCount: number | null;
|
||||
};
|
||||
};
|
||||
alerts: Alert[];
|
||||
}
|
||||
|
||||
export const ALERT_THRESHOLDS = {
|
||||
spike: 2.0,
|
||||
drop: -1.5,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import type { PostAnalytics, DayOfWeekMetrics, HeatmapReport } from "../models/types.js";
|
||||
|
||||
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
// Convert JS getDay() (0=Sun) to ISO weekday (1=Mon, 7=Sun)
|
||||
function toISOWeekday(jsDay: number): number {
|
||||
return jsDay === 0 ? 7 : jsDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a day-of-week performance heatmap from post analytics data.
|
||||
* Groups posts by day of week and calculates average metrics per day.
|
||||
*/
|
||||
export function generateHeatmap(posts: PostAnalytics[]): HeatmapReport {
|
||||
// Initialize buckets for all 7 days (ISO: 1=Mon to 7=Sun)
|
||||
const buckets: Map<number, PostAnalytics[]> = new Map();
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
buckets.set(i, []);
|
||||
}
|
||||
|
||||
// Group posts by ISO weekday
|
||||
for (const post of posts) {
|
||||
const jsDay = new Date(post.publishedDate).getUTCDay();
|
||||
const isoDay = toISOWeekday(jsDay);
|
||||
buckets.get(isoDay)!.push(post);
|
||||
}
|
||||
|
||||
// Build metrics per day
|
||||
const byDayOfWeek: DayOfWeekMetrics[] = [];
|
||||
for (let isoDay = 1; isoDay <= 7; isoDay++) {
|
||||
const dayPosts = buckets.get(isoDay)!;
|
||||
const jsDay = isoDay === 7 ? 0 : isoDay;
|
||||
const dayName = DAY_NAMES[jsDay];
|
||||
|
||||
if (dayPosts.length === 0) {
|
||||
byDayOfWeek.push({
|
||||
dayName,
|
||||
dayIndex: isoDay,
|
||||
postCount: 0,
|
||||
avgImpressions: 0,
|
||||
avgEngagementRate: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalImpressions = dayPosts.reduce((sum, p) => sum + p.metrics.impressions, 0);
|
||||
const totalEngagement = dayPosts.reduce((sum, p) => sum + p.metrics.engagementRate, 0);
|
||||
const bestPost = dayPosts.reduce((best, p) =>
|
||||
p.metrics.impressions > best.metrics.impressions ? p : best
|
||||
);
|
||||
|
||||
byDayOfWeek.push({
|
||||
dayName,
|
||||
dayIndex: isoDay,
|
||||
postCount: dayPosts.length,
|
||||
avgImpressions: Math.round(totalImpressions / dayPosts.length),
|
||||
avgEngagementRate: parseFloat((totalEngagement / dayPosts.length).toFixed(1)),
|
||||
bestPost,
|
||||
});
|
||||
}
|
||||
|
||||
// Find best days
|
||||
const daysWithPosts = byDayOfWeek.filter(d => d.postCount > 0);
|
||||
const bestDayImpressions = daysWithPosts.length > 0
|
||||
? daysWithPosts.reduce((best, d) => d.avgImpressions > best.avgImpressions ? d : best).dayName
|
||||
: "N/A";
|
||||
const bestDayEngagement = daysWithPosts.length > 0
|
||||
? daysWithPosts.reduce((best, d) => d.avgEngagementRate > best.avgEngagementRate ? d : best).dayName
|
||||
: "N/A";
|
||||
|
||||
// Date range
|
||||
const sortedDates = posts.map(p => p.publishedDate).sort();
|
||||
const dateRange = posts.length > 0
|
||||
? { from: sortedDates[0], to: sortedDates[sortedDates.length - 1] }
|
||||
: { from: "", to: "" };
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
postsAnalyzed: posts.length,
|
||||
dateRange,
|
||||
byDayOfWeek,
|
||||
bestDayImpressions,
|
||||
bestDayEngagement,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import type { PostAnalytics, MonthlyReport } from "../models/types.js";
|
||||
import { loadAllPosts, loadMonthlyReport, saveMonthlyReport } from "../utils/storage.js";
|
||||
import { mean } from "../utils/stats.js";
|
||||
import { detectAlerts } from "../utils/alerts.js";
|
||||
import { getISOWeek } from "./weekly.js";
|
||||
|
||||
/**
|
||||
* Get previous month string (e.g., "2026-03" → "2026-02")
|
||||
*/
|
||||
function getPreviousMonth(month: string): string {
|
||||
const [year, m] = month.split("-").map(Number);
|
||||
if (m === 1) return `${year - 1}-12`;
|
||||
return `${year}-${String(m - 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a monthly report with optional MoM comparison.
|
||||
* Saves the report to disk and returns it.
|
||||
*/
|
||||
export function generateMonthlyReport(root: string, month: string): MonthlyReport {
|
||||
const allPosts = loadAllPosts(root);
|
||||
const monthPosts = allPosts.filter(p => p.publishedDate.startsWith(month));
|
||||
|
||||
// Summary
|
||||
const totalPosts = monthPosts.length;
|
||||
const totalImpressions = monthPosts.reduce((s, p) => s + p.metrics.impressions, 0);
|
||||
const totalReactions = monthPosts.reduce((s, p) => s + p.metrics.reactions, 0);
|
||||
const totalComments = monthPosts.reduce((s, p) => s + p.metrics.comments, 0);
|
||||
const totalShares = monthPosts.reduce((s, p) => s + p.metrics.shares, 0);
|
||||
const totalClicks = monthPosts.reduce((s, p) => s + p.metrics.clicks, 0);
|
||||
const avgEngagementRate = totalPosts > 0
|
||||
? parseFloat(mean(monthPosts.map(p => p.metrics.engagementRate)).toFixed(2))
|
||||
: 0;
|
||||
const avgImpressionsPerPost = totalPosts > 0
|
||||
? Math.round(totalImpressions / totalPosts)
|
||||
: 0;
|
||||
|
||||
// Top performers (sorted by impressions desc)
|
||||
const topPerformers = [...monthPosts]
|
||||
.sort((a, b) => b.metrics.impressions - a.metrics.impressions)
|
||||
.slice(0, 5);
|
||||
|
||||
// Weekly breakdown
|
||||
const weekBuckets = new Map<string, PostAnalytics[]>();
|
||||
for (const post of monthPosts) {
|
||||
const week = getISOWeek(new Date(post.publishedDate + "T00:00:00Z"));
|
||||
if (!weekBuckets.has(week)) weekBuckets.set(week, []);
|
||||
weekBuckets.get(week)!.push(post);
|
||||
}
|
||||
|
||||
const byWeek = Array.from(weekBuckets.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([week, posts]) => ({
|
||||
week,
|
||||
postCount: posts.length,
|
||||
avgImpressions: Math.round(mean(posts.map(p => p.metrics.impressions))),
|
||||
avgEngagementRate: parseFloat(mean(posts.map(p => p.metrics.engagementRate)).toFixed(1)),
|
||||
}));
|
||||
|
||||
// MoM comparison
|
||||
const prevMonth = getPreviousMonth(month);
|
||||
const prevReport = loadMonthlyReport(root, prevMonth);
|
||||
|
||||
let trends: MonthlyReport["trends"];
|
||||
if (prevReport && prevReport.summary.totalPosts > 0) {
|
||||
const pctImpr = prevReport.summary.totalImpressions > 0
|
||||
? parseFloat(((totalImpressions - prevReport.summary.totalImpressions) / prevReport.summary.totalImpressions * 100).toFixed(1))
|
||||
: null;
|
||||
const pctEng = prevReport.summary.avgEngagementRate > 0
|
||||
? parseFloat(((avgEngagementRate - prevReport.summary.avgEngagementRate) / prevReport.summary.avgEngagementRate * 100).toFixed(1))
|
||||
: null;
|
||||
const pctPosts = prevReport.summary.totalPosts > 0
|
||||
? parseFloat(((totalPosts - prevReport.summary.totalPosts) / prevReport.summary.totalPosts * 100).toFixed(1))
|
||||
: null;
|
||||
|
||||
trends = {
|
||||
comparedTo: prevMonth,
|
||||
percentChange: {
|
||||
impressions: pctImpr,
|
||||
engagement: pctEng,
|
||||
postCount: pctPosts,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
trends = {
|
||||
comparedTo: null,
|
||||
percentChange: { impressions: null, engagement: null, postCount: null },
|
||||
};
|
||||
}
|
||||
|
||||
// Alerts
|
||||
const alerts = totalPosts > 0 ? detectAlerts(monthPosts, "impressions") : [];
|
||||
|
||||
const report: MonthlyReport = {
|
||||
month,
|
||||
generatedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
totalPosts,
|
||||
totalImpressions,
|
||||
totalReactions,
|
||||
totalComments,
|
||||
totalShares,
|
||||
totalClicks,
|
||||
avgEngagementRate,
|
||||
avgImpressionsPerPost,
|
||||
},
|
||||
topPerformers,
|
||||
byWeek,
|
||||
trends,
|
||||
alerts,
|
||||
};
|
||||
|
||||
// Save report
|
||||
saveMonthlyReport(root, report);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AnalyticsBatch, WeeklyReport, PostAnalytics } from "../models/types.js";
|
||||
import type { AnalyticsBatch, WeeklyReport, MonthlyReport, PostAnalytics } from "../models/types.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ export function getAnalyticsRoot(): string {
|
|||
* Ensure required subdirectories exist under analytics root
|
||||
*/
|
||||
export function ensureDirectories(root: string): void {
|
||||
const directories = ["exports", "posts", "weekly-reports"];
|
||||
const directories = ["exports", "posts", "weekly-reports", "monthly-reports"];
|
||||
|
||||
if (!existsSync(root)) {
|
||||
mkdirSync(root, { recursive: true });
|
||||
|
|
@ -252,3 +252,39 @@ export function loadAllWeeklyReports(root: string): WeeklyReport[] {
|
|||
b.week.localeCompare(a.week)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize month string to only allow YYYY-MM format
|
||||
*/
|
||||
function sanitizeMonth(month: string): string {
|
||||
if (!/^\d{4}-\d{2}$/.test(month)) {
|
||||
throw new Error(`Invalid month format: ${month}. Expected YYYY-MM`);
|
||||
}
|
||||
return month;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a monthly report to disk
|
||||
*/
|
||||
export function saveMonthlyReport(root: string, report: MonthlyReport): string {
|
||||
ensureDirectories(root);
|
||||
const reportsDir = join(root, "monthly-reports");
|
||||
const month = sanitizeMonth(report.month);
|
||||
const filename = `${month}.json`;
|
||||
const filepath = join(reportsDir, filename);
|
||||
verifyPathWithinDirectory(filepath, reportsDir);
|
||||
writeFileSync(filepath, JSON.stringify(report, null, 2), "utf-8");
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific monthly report by month identifier
|
||||
*/
|
||||
export function loadMonthlyReport(root: string, month: string): MonthlyReport | null {
|
||||
month = sanitizeMonth(month);
|
||||
const reportsDir = join(root, "monthly-reports");
|
||||
const filepath = join(reportsDir, `${month}.json`);
|
||||
if (!existsSync(filepath)) return null;
|
||||
const content = readFileSync(filepath, "utf-8");
|
||||
return JSON.parse(content) as MonthlyReport;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue