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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue