feat(linkedin-studio): N16 — out-of-network-andel + patterns-oppdatering + boundary-map [skip-docs]
Reach-splitten (in/out-of-network) er native i LinkedIns post-analytics siden juni 2026, men vises som PROSENT og finnes ikke i CSV-eksporten. Planen antok to manuelle antall; verifiseringen viste prosent, så modellen er ett felt — outOfNetworkPct — og in-network er komplementet. - parseOptionalPercent: egen parser, ikke parseOptionalCount. Komma er desimal (36,5 -> 36.5, aldri 365), og verdi >100 avvises: i én kolonne kan ikke et absolutt antall skilles fra en andel, så svaret er unknown, ikke en gjetning. Blank/ikke-numerisk/negativ -> unknown; ekte 0 beholdes. - Ett lagret halvpart, kryssjekket: In-network godtas og lagres som komplement; et transkribert par som ikke summerer til ~100 (±1 avrunding) forkastes som unknown i stedet for å bli halvveis trodd. - weightedOutOfNetworkPct: impressions-vektet roll-up (avgOutOfNetworkPct, uke + måned). Flatt snitt lar en 50-visnings-post slå en på 10 000; poster uten avlesning ekskluderes, og null vekt gir undefined — aldri 0, aldri NaN. - Reach inngår ALDRI i engagementRate (distribusjon != engasjement). Rapporten leser den som akvisisjon (ut) vs resonans (inn), og sier «ikke ført for denne perioden» framfor å estimere. En reach-innsikt går inn i N15s do-next-kanal. - Step 7c (A2-F11): rapporten tilbyr diff mot brukerens engagement-patterns.md med eksplisitt go — aldri stille skriving, aldri inn i den shippede malen. - Boundary-map (E#9): dwell eksplisitt umålbar, saves partner-gated, reach native men CSV-eksport uverifisert. - Reach-frie importer er byte-identiske med før, på skjerm og på disk. TDD: rødt bevist først (10 feilende), analytics 119 -> 144 tester, tsc ren. test-runner 232 -> 247 (Section 16w, gulv 213 -> 228). Alle suiter grønne. CHANGELOG: N15-oppføringen manglet og er backfilt sammen med N16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
This commit is contained in:
parent
b45fdad911
commit
63506f7d5c
21 changed files with 841 additions and 13 deletions
|
|
@ -6,7 +6,7 @@ import {
|
|||
loadAllPosts,
|
||||
} from "./utils/storage.js";
|
||||
import { detectAlerts } from "./utils/alerts.js";
|
||||
import { mean, standardDeviation } from "./utils/stats.js";
|
||||
import { mean, standardDeviation, weightedOutOfNetworkPct } from "./utils/stats.js";
|
||||
import { generateWeeklyReport, getCurrentISOWeek } from "./reports/weekly.js";
|
||||
import { generateHeatmap } from "./reports/heatmap.js";
|
||||
import { generateMonthlyReport } from "./reports/monthly.js";
|
||||
|
|
@ -30,6 +30,19 @@ function savesSuffix(saves?: number): string {
|
|||
return saves !== undefined ? ` | ${saves.toLocaleString()} saves` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-post reach suffix. Empty string when the post carries no manual
|
||||
* out-of-network share, so reach-free output stays identical to the pre-N16 CLI.
|
||||
*/
|
||||
function reachSuffix(outOfNetworkPct?: number): string {
|
||||
return outOfNetworkPct !== undefined ? ` | ${outOfNetworkPct}% out-of-network` : "";
|
||||
}
|
||||
|
||||
/** Round to one decimal — keeps the derived in-network half free of float noise. */
|
||||
function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
LinkedIn Analytics CLI
|
||||
|
|
@ -90,6 +103,15 @@ async function handleImport(root: string, args: string[]) {
|
|||
console.log(`Saves entered: ${totalSaves.toLocaleString()} across ${savesPosts.length} post(s) (manual)`);
|
||||
}
|
||||
|
||||
// Same for the reach split when the CSV carried an Out-of-network (or
|
||||
// In-network) column. Counting the posts that carry a reading makes partial
|
||||
// coverage visible instead of implying the whole batch was transcribed.
|
||||
const reachPosts = batch.posts.filter((p) => p.metrics.outOfNetworkPct !== undefined);
|
||||
if (reachPosts.length > 0) {
|
||||
const weighted = weightedOutOfNetworkPct(batch.posts);
|
||||
console.log(`Reach entered: ${weighted}% out-of-network across ${reachPosts.length} post(s) (manual, impressions-weighted)`);
|
||||
}
|
||||
|
||||
// Run alert detection on imported posts
|
||||
const alerts = detectAlerts(batch.posts, "impressions");
|
||||
|
||||
|
|
@ -144,6 +166,10 @@ async function handleReport(root: string, args: string[]) {
|
|||
if (report.summary.totalSaves !== undefined) {
|
||||
console.log(`Total saves: ${report.summary.totalSaves.toLocaleString()} (manual entry — top engagement signal)`);
|
||||
}
|
||||
if (report.summary.avgOutOfNetworkPct !== undefined) {
|
||||
console.log(`Out-of-network: ${report.summary.avgOutOfNetworkPct}% of impressions (manual entry — acquisition signal)`);
|
||||
console.log(`In-network: ${round1(100 - report.summary.avgOutOfNetworkPct)}% of impressions (resonance with the audience you have)`);
|
||||
}
|
||||
console.log(`Avg engagement: ${report.summary.avgEngagementRate.toFixed(2)}%`);
|
||||
console.log(`Avg impressions: ${Math.round(report.summary.avgImpressionsPerPost).toLocaleString()} per post`);
|
||||
console.log();
|
||||
|
|
@ -154,7 +180,7 @@ async function handleReport(root: string, args: string[]) {
|
|||
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(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)}${reachSuffix(post.metrics.outOfNetworkPct)} | ${post.publishedDate}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
|
@ -165,7 +191,7 @@ async function handleReport(root: string, args: string[]) {
|
|||
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(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)}${reachSuffix(post.metrics.outOfNetworkPct)} | ${post.publishedDate}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
|
@ -342,6 +368,10 @@ async function handleMonthlyReport(root: string, month: string) {
|
|||
if (s.totalSaves !== undefined) {
|
||||
console.log(`Saves: ${s.totalSaves.toLocaleString()} (manual entry — top engagement signal)`);
|
||||
}
|
||||
if (s.avgOutOfNetworkPct !== undefined) {
|
||||
console.log(`Out-of-network: ${s.avgOutOfNetworkPct}% of impressions (manual entry — acquisition signal)`);
|
||||
console.log(`In-network: ${round1(100 - s.avgOutOfNetworkPct)}% of impressions (resonance with the audience you have)`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
if (report.byWeek.length > 0) {
|
||||
|
|
@ -359,7 +389,7 @@ async function handleMonthlyReport(root: string, month: string) {
|
|||
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(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng${savesSuffix(post.metrics.saves)}${reachSuffix(post.metrics.outOfNetworkPct)} | ${post.publishedDate}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,24 @@ export interface PostMetrics {
|
|||
// It is deliberately NOT folded into engagementRate (which stays comparable
|
||||
// to historical, saves-free data) — saves is surfaced as its own signal.
|
||||
saves?: number;
|
||||
// `outOfNetworkPct` is OPTIONAL and manually entered — the share (0–100) of a
|
||||
// post's impressions that came from people who did NOT follow or connect with
|
||||
// the author. LinkedIn shows the in-network/out-of-network split natively in
|
||||
// post analytics (Discovery section, under Impressions; progressive global
|
||||
// rollout from June 2026) as a PERCENTAGE split, and does NOT put it in the
|
||||
// CSV export — so the ingest is a percent cell the user adds to the CSV, read
|
||||
// off that panel (see csv-parser.ts).
|
||||
//
|
||||
// Only the out-of-network half is stored: in-network is its complement by
|
||||
// definition (they describe one split), so keeping both would allow a
|
||||
// self-contradicting record. A missing column, a blank cell, a non-numeric
|
||||
// cell, a negative, or a value above 100 stays undefined — "unknown", never
|
||||
// coerced to 0; a genuine 0 is kept as 0 (nothing left the network).
|
||||
//
|
||||
// NOT folded into engagementRate: reach is a distribution signal, not
|
||||
// engagement. High out-of-network = the post acquired new audience; high
|
||||
// in-network engagement = it deepened the existing one.
|
||||
outOfNetworkPct?: number;
|
||||
// NOTE: `dwell` remains absent and unmeasurable. Dwell time is internal to
|
||||
// LinkedIn for organic posts — not exportable, no UI count to transcribe, no
|
||||
// API. Do not fabricate a dwell field or surface.
|
||||
|
|
@ -48,6 +66,10 @@ export interface WeeklyReport {
|
|||
totalShares: number;
|
||||
totalClicks: number;
|
||||
totalSaves?: number; // optional — present only when ≥1 post carries manual saves data
|
||||
// Optional — present only when ≥1 post carries a manual out-of-network share.
|
||||
// Impressions-WEIGHTED, so a small post with a high share cannot outvote a
|
||||
// large one (see weightedOutOfNetworkPct).
|
||||
avgOutOfNetworkPct?: number;
|
||||
avgEngagementRate: number;
|
||||
avgImpressionsPerPost: number;
|
||||
};
|
||||
|
|
@ -122,6 +144,9 @@ export interface MonthlyReport {
|
|||
totalShares: number;
|
||||
totalClicks: number;
|
||||
totalSaves?: number; // optional — present only when ≥1 post carries manual saves data
|
||||
// Optional — present only when ≥1 post carries a manual out-of-network share
|
||||
// (impressions-weighted; see weightedOutOfNetworkPct).
|
||||
avgOutOfNetworkPct?: number;
|
||||
avgEngagementRate: number;
|
||||
avgImpressionsPerPost: number;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -84,6 +84,64 @@ function parseOptionalCount(value: string): number | undefined {
|
|||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounding slack, in percentage points, allowed between the two halves of the
|
||||
* reach split. LinkedIn rounds each half independently for display, so a
|
||||
* transcribed "63% / 37%" can legitimately sum to 99 or 101.
|
||||
*/
|
||||
const REACH_SPLIT_TOLERANCE = 1;
|
||||
|
||||
/** Round to one decimal — the UI reading is itself a rounded percentage. */
|
||||
function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an OPTIONAL manually-entered PERCENTAGE share (out-of-network reach).
|
||||
* Distinct from parseOptionalCount in two ways that matter:
|
||||
* - a share never carries a thousands separator, so a comma is always the
|
||||
* DECIMAL mark here ("36,5" → 36.5). parseOptionalCount's US-thousands rule
|
||||
* would read that as 365.
|
||||
* - a share above 100 is not a share. It is most likely an absolute
|
||||
* impression count pasted into a percent column, and one column cannot tell
|
||||
* a count from a share — so the honest answer is unknown, never a guess.
|
||||
* Otherwise the same contract as the saves field:
|
||||
* - blank / absent / non-numeric / negative → undefined ("unknown", never 0)
|
||||
* - a genuine "0" → 0 (nothing left the network)
|
||||
* - "37%", "37 %", "37" → 37
|
||||
*/
|
||||
function parseOptionalPercent(value: string): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const cleaned = value.replace(/"/g, "").replace(/%/g, "").trim();
|
||||
if (cleaned === "") return undefined;
|
||||
|
||||
const parsed = Number(cleaned.replace(/,/g, "."));
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) return undefined;
|
||||
return round1(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce whichever halves of the reach split the user transcribed to the single
|
||||
* stored value: the out-of-network share.
|
||||
* - out-of-network only → that value
|
||||
* - in-network only → its complement (they describe one split)
|
||||
* - both, consistent → the out-of-network reading
|
||||
* - both, contradictory → undefined. One cell is a misreading and we cannot
|
||||
* tell which, so the record stays unknown rather than silently trusting one.
|
||||
*/
|
||||
function resolveOutOfNetworkPct(
|
||||
outOfNetwork: number | undefined,
|
||||
inNetwork: number | undefined
|
||||
): number | undefined {
|
||||
if (outOfNetwork !== undefined && inNetwork !== undefined) {
|
||||
const sum = outOfNetwork + inNetwork;
|
||||
return Math.abs(sum - 100) <= REACH_SPLIT_TOLERANCE ? outOfNetwork : undefined;
|
||||
}
|
||||
if (outOfNetwork !== undefined) return outOfNetwork;
|
||||
if (inNetwork !== undefined) return round1(100 - inNetwork);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes date to YYYY-MM-DD format
|
||||
* Handles: DD.MM.YYYY, MM/DD/YYYY, YYYY-MM-DD
|
||||
|
|
@ -225,6 +283,19 @@ export function parseLinkedInCSV(
|
|||
metrics.saves = saves;
|
||||
}
|
||||
|
||||
// Optional manual-entry reach split: only when the user augmented this CSV
|
||||
// with an Out-of-network (or In-network) column, read off the native
|
||||
// Discovery panel in post analytics — LinkedIn does not export it. Either
|
||||
// half is accepted and reduced to the out-of-network share; anything
|
||||
// unreadable or self-contradicting stays undefined ("unknown", never 0).
|
||||
const outOfNetworkPct = resolveOutOfNetworkPct(
|
||||
parseOptionalPercent(findColumn(record, ["out-of-network", "out of network", "outofnetwork"])),
|
||||
parseOptionalPercent(findColumn(record, ["in-network", "in network", "innetwork"]))
|
||||
);
|
||||
if (outOfNetworkPct !== undefined) {
|
||||
metrics.outOfNetworkPct = outOfNetworkPct;
|
||||
}
|
||||
|
||||
return {
|
||||
id: generatePostId(title, date),
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { PostAnalytics, MonthlyReport } from "../models/types.js";
|
||||
import { loadAllPosts, loadMonthlyReport, saveMonthlyReport } from "../utils/storage.js";
|
||||
import { mean } from "../utils/stats.js";
|
||||
import { mean, weightedOutOfNetworkPct } from "../utils/stats.js";
|
||||
import { detectAlerts } from "../utils/alerts.js";
|
||||
import { getISOWeek } from "./weekly.js";
|
||||
|
||||
|
|
@ -34,6 +34,9 @@ export function generateMonthlyReport(root: string, month: string): MonthlyRepor
|
|||
const totalSaves = savesPosts.length > 0
|
||||
? savesPosts.reduce((s, p) => s + (p.metrics.saves ?? 0), 0)
|
||||
: undefined;
|
||||
// Optional out-of-network share: impressions-weighted, present only when ≥1
|
||||
// post carries a reading — keeps reach-free months identical to pre-N16 output.
|
||||
const avgOutOfNetworkPct = weightedOutOfNetworkPct(monthPosts);
|
||||
const avgEngagementRate = totalPosts > 0
|
||||
? parseFloat(mean(monthPosts.map(p => p.metrics.engagementRate)).toFixed(2))
|
||||
: 0;
|
||||
|
|
@ -108,6 +111,7 @@ export function generateMonthlyReport(root: string, month: string): MonthlyRepor
|
|||
totalShares,
|
||||
totalClicks,
|
||||
...(totalSaves !== undefined ? { totalSaves } : {}),
|
||||
...(avgOutOfNetworkPct !== undefined ? { avgOutOfNetworkPct } : {}),
|
||||
avgEngagementRate,
|
||||
avgImpressionsPerPost,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { PostAnalytics, WeeklyReport } from "../models/types.js";
|
||||
import { mean, trendDirection, percentChange } from "../utils/stats.js";
|
||||
import { mean, trendDirection, percentChange, weightedOutOfNetworkPct } from "../utils/stats.js";
|
||||
import { detectAlerts, detectWeeklyAlerts } from "../utils/alerts.js";
|
||||
import { loadAllPosts, loadWeeklyReport, saveWeeklyReport } from "../utils/storage.js";
|
||||
|
||||
|
|
@ -173,6 +173,14 @@ export function generateWeeklyReport(analyticsRoot: string, week?: string): Week
|
|||
report.summary.totalSaves = totalSaves;
|
||||
}
|
||||
|
||||
// Same contract for the out-of-network share: impressions-weighted, and only
|
||||
// present when at least one post carried a reading (reach-free reports stay
|
||||
// byte-identical to pre-N16 output).
|
||||
const avgOutOfNetworkPct = weightedOutOfNetworkPct(weekPosts);
|
||||
if (avgOutOfNetworkPct !== undefined) {
|
||||
report.summary.avgOutOfNetworkPct = avgOutOfNetworkPct;
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
const engagementRates = weekPosts.map(post => post.metrics.engagementRate);
|
||||
report.summary.avgEngagementRate = mean(engagementRates);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,43 @@ export function percentChange(current: number, previous: number): number {
|
|||
return ((current - previous) / previous) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal shape needed to weight a reach share — keeps this helper usable from
|
||||
* both report builders without dragging in the full PostAnalytics record.
|
||||
*/
|
||||
interface ReachWeightable {
|
||||
metrics: { impressions: number; outOfNetworkPct?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll per-post out-of-network shares up to one number, WEIGHTED by impressions.
|
||||
*
|
||||
* The share is a fraction of a post's own impressions, so a flat mean would let
|
||||
* a 50-impression post at 90% outvote a 10,000-impression post at 20%. Posts
|
||||
* without a share are excluded entirely — folding them in as 0 would invent
|
||||
* data that was never entered.
|
||||
*
|
||||
* Returns undefined when no post carries a share, or when the posts that do
|
||||
* carry one have no impressions to weight (a share of zero impressions has no
|
||||
* meaning; 0 would be a fabricated reading and NaN a bug).
|
||||
*/
|
||||
export function weightedOutOfNetworkPct(posts: ReachWeightable[]): number | undefined {
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
let sawShare = false;
|
||||
|
||||
for (const post of posts) {
|
||||
const pct = post.metrics.outOfNetworkPct;
|
||||
if (pct === undefined) continue;
|
||||
sawShare = true;
|
||||
totalWeight += post.metrics.impressions;
|
||||
weightedSum += post.metrics.impressions * pct;
|
||||
}
|
||||
|
||||
if (!sawShare || totalWeight <= 0) return undefined;
|
||||
return Math.round((weightedSum / totalWeight) * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate how many standard deviations a value is from the mean.
|
||||
* Returns 0 if standard deviation is 0.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue