feat(linkedin-thought-leadership): v1.0.0 — initial open-source import

Build LinkedIn thought leadership with algorithmic understanding,
strategic consistency, and AI-assisted content creation. Updated for
the January 2026 360Brew algorithm change.

16 agents, 25 commands, 6 skills, 9 hooks, 24 reference docs.

Personal data sanitized: voice samples generalized to template,
high-engagement posts cleared, region-specific references replaced
with placeholders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-04-07 22:09:03 +02:00
commit 39f8b275a6
143 changed files with 32662 additions and 0 deletions

View file

@ -0,0 +1,311 @@
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 { join } from "node:path";
import { existsSync } from "node:fs";
import type { PostMetrics } 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;
}
function printUsage() {
console.log(`
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 trends [--period P] [--metric M] Show trends and alerts
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 build/cli.js import linkedin-export-2026-01-20.csv
node build/cli.js report --week 2026-W04
node build/cli.js 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 build/cli.js 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}`);
// 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 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()}`);
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 | ${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 | ${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 valid PostMetrics key
*/
function isPostMetric(value: string): value is keyof PostMetrics {
const validMetrics: (keyof PostMetrics)[] = [
"impressions",
"reactions",
"comments",
"shares",
"clicks",
"engagementRate",
];
return validMetrics.includes(value as keyof PostMetrics);
}
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: (keyof PostMetrics)[] = [
"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 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;
default:
printUsage();
process.exit(command ? 1 : 0);
}
}
main().catch((err) => {
console.error("Fatal error:", err instanceof Error ? err.message : String(err));
process.exit(1);
});

View file

@ -0,0 +1,74 @@
export interface PostAnalytics {
id: string; // Hash of title + date
title: string; // First ~100 chars of post content
publishedDate: string; // YYYY-MM-DD
metrics: PostMetrics;
importedAt: string; // ISO datetime
exportSource: string; // Original CSV filename
}
export interface PostMetrics {
impressions: number;
reactions: number;
comments: number;
shares: number;
clicks: number;
engagementRate: number; // (reactions+comments+shares+clicks)/impressions * 100
}
export interface AnalyticsBatch {
batchId: string; // UUID-like identifier
importedAt: string; // ISO datetime
exportFilename: string;
dateRange: { from: string; to: string };
postCount: number;
posts: PostAnalytics[];
}
export interface WeeklyReport {
week: string; // ISO week e.g. "2026-W05"
generatedAt: string;
summary: {
totalPosts: number;
totalImpressions: number;
totalReactions: number;
totalComments: number;
totalShares: number;
totalClicks: number;
avgEngagementRate: number;
avgImpressionsPerPost: number;
};
topPerformers: PostAnalytics[];
underperformers: PostAnalytics[];
trends: {
impressionsTrend: TrendDirection;
engagementTrend: TrendDirection;
comparedTo: string;
percentChange: {
impressions: number;
engagement: number;
};
};
alerts: Alert[];
}
export type TrendDirection = "up" | "down" | "stable";
export interface Alert {
type: "spike" | "drop" | "milestone";
severity: "info" | "warning" | "critical";
metric: string;
message: string;
postId?: string;
value: number;
baseline: number;
deviations: number;
}
export const ALERT_THRESHOLDS = {
spike: 2.0,
drop: -1.5,
weeklyDropWarning: -30,
weeklyDropCritical: -50,
weeklySpikeInfo: 100,
} as const;

View file

@ -0,0 +1,221 @@
import { parse } from "csv-parse/sync";
import { readFileSync } from "node:fs";
import type { PostAnalytics, AnalyticsBatch, PostMetrics } from "../models/types.js";
/**
* Detects delimiter (comma vs semicolon) by checking first line
*/
function detectDelimiter(content: string): string {
const firstLine = content.split("\n")[0];
const commaCount = (firstLine.match(/,/g) || []).length;
const semicolonCount = (firstLine.match(/;/g) || []).length;
return semicolonCount > commaCount ? ";" : ",";
}
/**
* Finds column value using fuzzy pattern matching
*/
function findColumn(record: Record<string, string>, patterns: string[]): string {
const keys = Object.keys(record);
for (const pattern of patterns) {
const key = keys.find((k) =>
k.toLowerCase().includes(pattern.toLowerCase())
);
if (key) {
return record[key];
}
}
return "";
}
/**
* Parses metric value, handling both US (4,523) and EU (4.523) thousand separators
* Clamps negative values to 0
*/
function parseMetric(value: string): number {
if (!value) return 0;
// Remove quotes and trim
const cleaned = value.replace(/"/g, "").trim();
// Check if it looks like EU format (4.523) or US format (4,523)
// EU format has dots as thousand separators, US has commas
// If there's both comma and dot, the last one is decimal separator
const lastComma = cleaned.lastIndexOf(",");
const lastDot = cleaned.lastIndexOf(".");
let normalized = cleaned;
if (lastComma > lastDot) {
// US format: remove commas (thousand separator), keep dots
normalized = cleaned.replace(/,/g, "");
} else {
// EU format: remove dots (thousand separator), replace comma with dot
normalized = cleaned.replace(/\./g, "").replace(/,/g, ".");
}
const parsed = parseFloat(normalized) || 0;
// Clamp negative values to 0
return Math.max(0, parsed);
}
/**
* Normalizes date to YYYY-MM-DD format
* Handles: DD.MM.YYYY, MM/DD/YYYY, YYYY-MM-DD
* Returns null if date is invalid
*/
function normalizeDate(dateStr: string): string | null {
if (!dateStr) return null;
const cleaned = dateStr.replace(/"/g, "").trim();
// Already in YYYY-MM-DD format
if (/^\d{4}-\d{2}-\d{2}$/.test(cleaned)) {
return cleaned;
}
// DD.MM.YYYY format
if (/^\d{2}\.\d{2}\.\d{4}$/.test(cleaned)) {
const [day, month, year] = cleaned.split(".");
return `${year}-${month}-${day}`;
}
// MM/DD/YYYY format
if (/^\d{2}\/\d{2}\/\d{4}$/.test(cleaned)) {
const [month, day, year] = cleaned.split("/");
return `${year}-${month}-${day}`;
}
// YYYY/MM/DD format
if (/^\d{4}\/\d{2}\/\d{2}$/.test(cleaned)) {
return cleaned.replace(/\//g, "-");
}
// Invalid date format
return null;
}
/**
* Simple string hash function for generating deterministic post IDs
*/
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(36);
}
/**
* Generates deterministic post ID from title and date
*/
function generatePostId(title: string, date: string): string {
return simpleHash(`${title}:${date}`);
}
/**
* Generates batch ID using timestamp
*/
function generateBatchId(): string {
const now = new Date();
const timestamp = now.getTime();
return `batch-${timestamp}-${simpleHash(timestamp.toString())}`;
}
/**
* Parses LinkedIn CSV export into structured AnalyticsBatch
*/
export function parseLinkedInCSV(
filePath: string,
filename: string
): AnalyticsBatch {
// Read file
let content = readFileSync(filePath, "utf-8");
// Strip BOM if present
if (content.charCodeAt(0) === 0xfeff) {
content = content.slice(1);
}
// Detect delimiter
const delimiter = detectDelimiter(content);
// Parse CSV
const records = parse(content, {
columns: true,
skip_empty_lines: true,
delimiter,
quote: '"',
trim: true,
}) as Record<string, string>[];
// Normalize records into PostAnalytics, skipping invalid records
const posts: PostAnalytics[] = records
.map((record, index) => {
const title = findColumn(record, ["content", "title", "post"]);
const dateStr = findColumn(record, ["date", "published", "posted"]);
const date = normalizeDate(dateStr);
// Skip records with empty titles
if (!title || title.trim() === "") {
console.warn(`Warning: Skipping record at line ${index + 2}: empty title`);
return null;
}
// Skip records with invalid dates
if (!date) {
console.warn(`Warning: Skipping record at line ${index + 2}: invalid date "${dateStr}"`);
return null;
}
const impressions = parseMetric(findColumn(record, ["impression", "view"]));
const reactions = parseMetric(findColumn(record, ["reaction", "like"]));
const comments = parseMetric(findColumn(record, ["comment"]));
const shares = parseMetric(findColumn(record, ["share", "repost"]));
const clicks = parseMetric(findColumn(record, ["click"]));
// Calculate engagement rate
const totalEngagement = reactions + comments + shares + clicks;
const engagementRate = impressions > 0
? (totalEngagement / impressions) * 100
: 0;
const metrics: PostMetrics = {
impressions,
reactions,
comments,
shares,
clicks,
engagementRate,
};
return {
id: generatePostId(title, date),
title,
publishedDate: date,
metrics,
importedAt: new Date().toISOString(),
exportSource: filename,
};
})
.filter((post): post is PostAnalytics => post !== null);
// Find date range
const dates = posts.map((p) => p.publishedDate).filter((d) => d);
const sortedDates = dates.sort();
const dateRange = {
from: sortedDates[0] || "",
to: sortedDates[sortedDates.length - 1] || "",
};
// Build AnalyticsBatch
const batch: AnalyticsBatch = {
batchId: generateBatchId(),
importedAt: new Date().toISOString(),
exportFilename: filename,
dateRange,
postCount: posts.length,
posts,
};
return batch;
}

View file

@ -0,0 +1,233 @@
import type { PostAnalytics, WeeklyReport } from "../models/types.js";
import { mean, trendDirection, percentChange } from "../utils/stats.js";
import { detectAlerts, detectWeeklyAlerts } from "../utils/alerts.js";
import { loadAllPosts, loadWeeklyReport, saveWeeklyReport } from "../utils/storage.js";
/**
* Get current ISO week string (e.g., "2026-W05").
* Uses ISO 8601 week date system where Monday is first day of week.
*/
export function getCurrentISOWeek(): string {
return getISOWeek(new Date());
}
/**
* Get ISO week string for a specific date.
* Format: "YYYY-WXX" where XX is zero-padded week number.
*
* ISO 8601 week date rules:
* - Week starts on Monday
* - Week 1 is the week with the first Thursday of the year
* - Last week of year might extend into next year
*/
export function getISOWeek(date: Date): string {
// Copy date to avoid mutating original
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
// Set to nearest Thursday: current date + 4 - current day number
// Make Sunday's day number 7
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
// Get first day of year
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
// Calculate full weeks to nearest Thursday
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
// Return ISO week format
const year = d.getUTCFullYear();
const weekStr = weekNo.toString().padStart(2, '0');
return `${year}-W${weekStr}`;
}
/**
* Filter posts to a specific ISO week.
* Posts are matched by converting their publishedDate to ISO week format.
*/
export function getPostsForWeek(posts: PostAnalytics[], week: string): PostAnalytics[] {
return posts.filter(post => {
const postDate = new Date(post.publishedDate);
const postWeek = getISOWeek(postDate);
return postWeek === week;
});
}
/**
* Get the ISO week string for the previous week.
* Uses proper ISO week calculation to handle year boundaries correctly.
*/
function getPreviousWeek(week: string): string {
// Parse week string (e.g., "2026-W05")
const match = week.match(/^(\d{4})-W(\d{2})$/);
if (!match) {
throw new Error(`Invalid week format: ${week}`);
}
const year = parseInt(match[1]);
const weekNum = parseInt(match[2]);
// ISO week 1 is the week containing January 4th
// Find Thursday of the target ISO week
const jan4 = new Date(Date.UTC(year, 0, 4));
// Find Monday of week 1 by going back from Jan 4 to Monday
const jan4Day = jan4.getUTCDay() || 7; // Sunday = 7 in ISO
const week1Monday = new Date(jan4.getTime() - (jan4Day - 1) * 24 * 60 * 60 * 1000);
// Add (weekNum - 1) * 7 days to get Monday of target week
const targetMonday = new Date(week1Monday.getTime() + (weekNum - 1) * 7 * 24 * 60 * 60 * 1000);
// Add 3 days to get Thursday of target week
const targetThursday = new Date(targetMonday.getTime() + 3 * 24 * 60 * 60 * 1000);
// Subtract 7 days to get previous week's Thursday
const previousThursday = new Date(targetThursday.getTime() - 7 * 24 * 60 * 60 * 1000);
// Use getISOWeek to get the correct ISO week string
return getISOWeek(previousThursday);
}
/**
* Generate a weekly report from imported analytics data.
*
* @param analyticsRoot - Root directory containing analytics data
* @param week - ISO week string (e.g., "2026-W05"). If not provided, uses current week.
* @returns WeeklyReport object
*
* Process:
* 1. Load all posts from storage
* 2. Filter posts for target week
* 3. Calculate summary metrics
* 4. Find top 3 performers and bottom 3 underperformers
* 5. Calculate trends vs previous week
* 6. Generate alerts
* 7. Save and return report
*
* Edge cases:
* - No posts for week zeroed summary
* - No previous week data stable trends with 0% change
* - Fewer than 3 posts shorter top/bottom lists
*/
export function generateWeeklyReport(analyticsRoot: string, week?: string): WeeklyReport {
// Determine target week
const targetWeek = week || getCurrentISOWeek();
// Load all posts
const allPosts = loadAllPosts(analyticsRoot);
// Filter posts for target week
const weekPosts = getPostsForWeek(allPosts, targetWeek);
// Initialize report structure
const report: WeeklyReport = {
week: targetWeek,
generatedAt: new Date().toISOString(),
summary: {
totalPosts: weekPosts.length,
totalImpressions: 0,
totalReactions: 0,
totalComments: 0,
totalShares: 0,
totalClicks: 0,
avgEngagementRate: 0,
avgImpressionsPerPost: 0,
},
topPerformers: [],
underperformers: [],
trends: {
impressionsTrend: "stable",
engagementTrend: "stable",
comparedTo: getPreviousWeek(targetWeek),
percentChange: {
impressions: 0,
engagement: 0,
},
},
alerts: [],
};
// If no posts, return early with zeroed report
if (weekPosts.length === 0) {
return report;
}
// Calculate summary metrics
for (const post of weekPosts) {
report.summary.totalImpressions += post.metrics.impressions;
report.summary.totalReactions += post.metrics.reactions;
report.summary.totalComments += post.metrics.comments;
report.summary.totalShares += post.metrics.shares;
report.summary.totalClicks += post.metrics.clicks;
}
// Calculate averages
const engagementRates = weekPosts.map(post => post.metrics.engagementRate);
report.summary.avgEngagementRate = mean(engagementRates);
report.summary.avgImpressionsPerPost = report.summary.totalImpressions / weekPosts.length;
// Find top 3 performers (highest engagement rate)
const sortedByEngagement = [...weekPosts].sort(
(a, b) => b.metrics.engagementRate - a.metrics.engagementRate
);
report.topPerformers = sortedByEngagement.slice(0, 3);
// Find bottom 3 underperformers (lowest engagement rate)
report.underperformers = sortedByEngagement
.slice()
.reverse()
.slice(0, 3);
// Calculate trends vs previous week
const previousWeek = getPreviousWeek(targetWeek);
const previousReport = loadWeeklyReport(analyticsRoot, previousWeek);
if (previousReport && previousReport.summary.totalPosts > 0) {
// Calculate percent changes
report.trends.percentChange.impressions = percentChange(
report.summary.totalImpressions,
previousReport.summary.totalImpressions
);
report.trends.percentChange.engagement = percentChange(
report.summary.avgEngagementRate,
previousReport.summary.avgEngagementRate
);
// Determine trend directions
report.trends.impressionsTrend = trendDirection(
report.summary.totalImpressions,
previousReport.summary.totalImpressions
);
report.trends.engagementTrend = trendDirection(
report.summary.avgEngagementRate,
previousReport.summary.avgEngagementRate
);
}
// Generate alerts
const postAlerts = detectAlerts(weekPosts, "impressions");
let weeklyAlerts: typeof report.alerts = [];
if (previousReport && previousReport.summary.totalPosts > 0) {
weeklyAlerts = detectWeeklyAlerts(
{
impressions: report.summary.totalImpressions,
engagementRate: report.summary.avgEngagementRate,
},
{
impressions: previousReport.summary.totalImpressions,
engagementRate: previousReport.summary.avgEngagementRate,
}
);
}
report.alerts = [...weeklyAlerts, ...postAlerts];
// Save report
saveWeeklyReport(analyticsRoot, report);
return report;
}

View file

@ -0,0 +1,162 @@
import type {
PostAnalytics,
Alert,
PostMetrics,
} from "../models/types.js";
import { ALERT_THRESHOLDS } from "../models/types.js";
import {
mean,
deviationsFromMean,
percentChange,
} from "./stats.js";
/**
* Analyze posts for spikes and drops based on standard deviation thresholds.
* For each post, checks if its metric value deviates significantly from the mean.
* Returns array of alerts sorted by severity (critical first).
*/
export function detectAlerts(
posts: PostAnalytics[],
metricKey: keyof PostMetrics = "impressions"
): Alert[] {
if (posts.length === 0) return [];
const alerts: Alert[] = [];
// Extract metric values
const values = posts.map((post) => post.metrics[metricKey]);
const avg = mean(values);
// Check each post for significant deviations
for (const post of posts) {
const value = post.metrics[metricKey];
const deviations = deviationsFromMean(value, values);
// Spike detection
if (deviations > ALERT_THRESHOLDS.spike) {
alerts.push({
type: "spike",
severity: "info",
metric: metricKey,
message: `Post "${post.title}" has unusually high ${metricKey}: ${value.toLocaleString()} (${deviations.toFixed(1)} std deviations above mean)`,
postId: post.id,
value,
baseline: avg,
deviations,
});
}
// Drop detection
if (deviations < ALERT_THRESHOLDS.drop) {
alerts.push({
type: "drop",
severity: "warning",
metric: metricKey,
message: `Post "${post.title}" has unusually low ${metricKey}: ${value.toLocaleString()} (${Math.abs(deviations).toFixed(1)} std deviations below mean)`,
postId: post.id,
value,
baseline: avg,
deviations,
});
}
}
// Sort by severity: critical > warning > info
const severityOrder = { critical: 0, warning: 1, info: 2 };
alerts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
return alerts;
}
/**
* Compare week-over-week metrics and generate alerts for significant changes.
* Uses percentChange and ALERT_THRESHOLDS for weekly drops and spikes.
*/
export function detectWeeklyAlerts(
currentWeekMetrics: { impressions: number; engagementRate: number },
previousWeekMetrics: { impressions: number; engagementRate: number }
): Alert[] {
const alerts: Alert[] = [];
// Analyze impressions
const impressionChange = percentChange(
currentWeekMetrics.impressions,
previousWeekMetrics.impressions
);
if (impressionChange < ALERT_THRESHOLDS.weeklyDropCritical) {
alerts.push({
type: "drop",
severity: "critical",
metric: "impressions",
message: `Critical drop in weekly impressions: ${impressionChange.toFixed(1)}% (from ${previousWeekMetrics.impressions.toLocaleString()} to ${currentWeekMetrics.impressions.toLocaleString()})`,
value: currentWeekMetrics.impressions,
baseline: previousWeekMetrics.impressions,
deviations: impressionChange / 10, // Rough conversion to deviations
});
} else if (impressionChange < ALERT_THRESHOLDS.weeklyDropWarning) {
alerts.push({
type: "drop",
severity: "warning",
metric: "impressions",
message: `Weekly impressions dropped by ${Math.abs(impressionChange).toFixed(1)}%: from ${previousWeekMetrics.impressions.toLocaleString()} to ${currentWeekMetrics.impressions.toLocaleString()}`,
value: currentWeekMetrics.impressions,
baseline: previousWeekMetrics.impressions,
deviations: impressionChange / 10,
});
} else if (impressionChange > ALERT_THRESHOLDS.weeklySpikeInfo) {
alerts.push({
type: "spike",
severity: "info",
metric: "impressions",
message: `Strong growth in weekly impressions: +${impressionChange.toFixed(1)}% (from ${previousWeekMetrics.impressions.toLocaleString()} to ${currentWeekMetrics.impressions.toLocaleString()})`,
value: currentWeekMetrics.impressions,
baseline: previousWeekMetrics.impressions,
deviations: impressionChange / 10,
});
}
// Analyze engagement rate
const engagementChange = percentChange(
currentWeekMetrics.engagementRate,
previousWeekMetrics.engagementRate
);
if (engagementChange < ALERT_THRESHOLDS.weeklyDropCritical) {
alerts.push({
type: "drop",
severity: "critical",
metric: "engagementRate",
message: `Critical drop in weekly engagement rate: ${engagementChange.toFixed(1)}% (from ${previousWeekMetrics.engagementRate.toFixed(2)}% to ${currentWeekMetrics.engagementRate.toFixed(2)}%)`,
value: currentWeekMetrics.engagementRate,
baseline: previousWeekMetrics.engagementRate,
deviations: engagementChange / 10,
});
} else if (engagementChange < ALERT_THRESHOLDS.weeklyDropWarning) {
alerts.push({
type: "drop",
severity: "warning",
metric: "engagementRate",
message: `Weekly engagement rate dropped by ${Math.abs(engagementChange).toFixed(1)}%: from ${previousWeekMetrics.engagementRate.toFixed(2)}% to ${currentWeekMetrics.engagementRate.toFixed(2)}%`,
value: currentWeekMetrics.engagementRate,
baseline: previousWeekMetrics.engagementRate,
deviations: engagementChange / 10,
});
} else if (engagementChange > ALERT_THRESHOLDS.weeklySpikeInfo) {
alerts.push({
type: "spike",
severity: "info",
metric: "engagementRate",
message: `Strong growth in weekly engagement rate: +${engagementChange.toFixed(1)}% (from ${previousWeekMetrics.engagementRate.toFixed(2)}% to ${currentWeekMetrics.engagementRate.toFixed(2)}%)`,
value: currentWeekMetrics.engagementRate,
baseline: previousWeekMetrics.engagementRate,
deviations: engagementChange / 10,
});
}
// Sort by severity: critical > warning > info
const severityOrder = { critical: 0, warning: 1, info: 2 };
alerts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
return alerts;
}

View file

@ -0,0 +1,63 @@
import type { TrendDirection } from "../models/types.js";
/**
* Calculate arithmetic mean of values.
* Returns 0 for empty array.
*/
export function mean(values: number[]): number {
if (values.length === 0) return 0;
const sum = values.reduce((acc, val) => acc + val, 0);
return sum / values.length;
}
/**
* Calculate population standard deviation.
* Returns 0 for empty or single-element array.
*/
export function standardDeviation(values: number[]): number {
if (values.length <= 1) return 0;
const avg = mean(values);
const squaredDiffs = values.map((val) => Math.pow(val - avg, 2));
const variance = mean(squaredDiffs);
return Math.sqrt(variance);
}
/**
* Determine trend direction based on percentage change.
* Returns "up" if change > threshold, "down" if change < -threshold, "stable" otherwise.
* Default threshold is 5%.
*/
export function trendDirection(
current: number,
previous: number,
threshold: number = 5
): TrendDirection {
const change = percentChange(current, previous);
if (change > threshold) return "up";
if (change < -threshold) return "down";
return "stable";
}
/**
* Calculate percentage change between current and previous values.
* Returns 0 if previous is 0.
*/
export function percentChange(current: number, previous: number): number {
if (previous === 0) return 0;
return ((current - previous) / previous) * 100;
}
/**
* Calculate how many standard deviations a value is from the mean.
* Returns 0 if standard deviation is 0.
*/
export function deviationsFromMean(value: number, values: number[]): number {
const avg = mean(values);
const stdDev = standardDeviation(values);
if (stdDev === 0) return 0;
return (value - avg) / stdDev;
}

View file

@ -0,0 +1,254 @@
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";
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Get the analytics root directory from environment or default location
* Default is assets/analytics relative to the plugin root
*/
export function getAnalyticsRoot(): string {
if (process.env.ANALYTICS_ROOT) {
return resolve(process.env.ANALYTICS_ROOT);
}
// Build output is at: scripts/analytics/build/utils/storage.js
// Plugin root is 4 levels up: ../../../../
// Then assets/analytics from there
const pluginRoot = resolve(__dirname, "../../../../");
return join(pluginRoot, "assets", "analytics");
}
/**
* Ensure required subdirectories exist under analytics root
*/
export function ensureDirectories(root: string): void {
const directories = ["exports", "posts", "weekly-reports"];
if (!existsSync(root)) {
mkdirSync(root, { recursive: true });
}
for (const dir of directories) {
const path = join(root, dir);
if (!existsSync(path)) {
mkdirSync(path, { recursive: true });
}
}
}
/**
* List all CSV export files in the exports directory
*/
export function listExports(root: string): string[] {
const exportsDir = join(root, "exports");
if (!existsSync(exportsDir)) {
return [];
}
return readdirSync(exportsDir)
.filter(file => file.endsWith(".csv"))
.sort();
}
/**
* Sanitize date string to only allow YYYY-MM-DD format
*/
function sanitizeDate(date: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD`);
}
return date;
}
/**
* Sanitize ID string to only allow alphanumeric and hyphens
*/
function sanitizeId(id: string): string {
if (!/^[a-zA-Z0-9-]+$/.test(id)) {
throw new Error(`Invalid ID format: ${id}. Only alphanumeric and hyphens allowed`);
}
return id;
}
/**
* Verify that the resolved path is within the expected directory
*/
function verifyPathWithinDirectory(filepath: string, expectedDir: string): void {
const resolvedPath = resolve(filepath);
const resolvedDir = resolve(expectedDir);
if (!resolvedPath.startsWith(resolvedDir + "/") && resolvedPath !== resolvedDir) {
throw new Error(`Path traversal detected: ${filepath} is not within ${expectedDir}`);
}
}
/**
* Save an analytics batch to disk
* Returns the filename that was created
*/
export function saveBatch(root: string, batch: AnalyticsBatch): string {
ensureDirectories(root);
const postsDir = join(root, "posts");
// Sanitize inputs to prevent path traversal
const date = sanitizeDate(batch.dateRange.from);
const shortId = sanitizeId(batch.batchId.substring(0, 8));
const filename = `${date}-${shortId}.json`;
const filepath = join(postsDir, filename);
// Verify the resolved filepath is within postsDir
verifyPathWithinDirectory(filepath, postsDir);
writeFileSync(filepath, JSON.stringify(batch, null, 2), "utf-8");
return filename;
}
/**
* Load all analytics batches from disk
* Returns batches sorted by importedAt timestamp
*/
export function loadAllBatches(root: string): AnalyticsBatch[] {
const postsDir = join(root, "posts");
if (!existsSync(postsDir)) {
return [];
}
const batches: AnalyticsBatch[] = [];
for (const file of readdirSync(postsDir)) {
if (!file.endsWith(".json")) {
continue;
}
const filepath = join(postsDir, file);
const content = readFileSync(filepath, "utf-8");
try {
const batch = JSON.parse(content) as AnalyticsBatch;
batches.push(batch);
} catch {
// Skip corrupt batch file
continue;
}
}
return batches.sort((a, b) =>
a.importedAt.localeCompare(b.importedAt)
);
}
/**
* Load all posts from all batches, deduplicated by post ID
* Latest import wins. Sorted by publishedDate descending.
*/
export function loadAllPosts(root: string): PostAnalytics[] {
const batches = loadAllBatches(root);
// Use Map to deduplicate - key is post ID, value is { post, importedAt }
const postMap = new Map<string, { post: PostAnalytics; importedAt: string }>();
for (const batch of batches) {
for (const post of batch.posts) {
const existing = postMap.get(post.id);
// Keep post with latest importedAt timestamp
if (!existing || batch.importedAt > existing.importedAt) {
postMap.set(post.id, {
post,
importedAt: batch.importedAt
});
}
}
}
// Extract posts and sort by publishedDate descending
const posts = Array.from(postMap.values()).map(({ post }) => post);
return posts.sort((a, b) =>
b.publishedDate.localeCompare(a.publishedDate)
);
}
/**
* Sanitize week string to only allow ISO week format (YYYY-WXX)
*/
function sanitizeWeek(week: string): string {
if (!/^\d{4}-W\d{2}$/.test(week)) {
throw new Error(`Invalid week format: ${week}. Expected YYYY-WXX`);
}
return week;
}
/**
* Save a weekly report to disk
* Returns the filename that was created
*/
export function saveWeeklyReport(root: string, report: WeeklyReport): string {
ensureDirectories(root);
const reportsDir = join(root, "weekly-reports");
// Sanitize week to prevent path traversal
const week = sanitizeWeek(report.week);
const filename = `${week}.json`;
const filepath = join(reportsDir, filename);
// Verify the resolved filepath is within reportsDir
verifyPathWithinDirectory(filepath, reportsDir);
writeFileSync(filepath, JSON.stringify(report, null, 2), "utf-8");
return filename;
}
/**
* Load a specific weekly report by week identifier
* Returns null if not found
*/
export function loadWeeklyReport(root: string, week: string): WeeklyReport | null {
week = sanitizeWeek(week);
const reportsDir = join(root, "weekly-reports");
const filepath = join(reportsDir, `${week}.json`);
if (!existsSync(filepath)) {
return null;
}
const content = readFileSync(filepath, "utf-8");
return JSON.parse(content) as WeeklyReport;
}
/**
* Load all weekly reports from disk
* Returns reports sorted by week descending (newest first)
*/
export function loadAllWeeklyReports(root: string): WeeklyReport[] {
const reportsDir = join(root, "weekly-reports");
if (!existsSync(reportsDir)) {
return [];
}
const reports: WeeklyReport[] = [];
for (const file of readdirSync(reportsDir)) {
if (!file.endsWith(".json")) {
continue;
}
const filepath = join(reportsDir, file);
const content = readFileSync(filepath, "utf-8");
const report = JSON.parse(content) as WeeklyReport;
reports.push(report);
}
return reports.sort((a, b) =>
b.week.localeCompare(a.week)
);
}