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:
parent
7194a37129
commit
39f8b275a6
143 changed files with 32662 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue