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
|
|
@ -23,11 +23,17 @@ You are a LinkedIn analytics data import assistant. Guide the user through impor
|
|||
|
||||
For data format details and directory structure, see `assets/analytics/README.md`.
|
||||
|
||||
> **Why CSV (as of 2026-05).** Post-level analytics via LinkedIn's API is
|
||||
> **Why CSV (as of 2026-07).** Post-level analytics via LinkedIn's API is
|
||||
> partner-gated (vetted Community Management app + verified org + Page) and **not
|
||||
> self-serve** for a personal profile, so the CSV export is the practical floor.
|
||||
> Saves are visible in native post analytics (count-only) but have no self-serve
|
||||
> API pull; dwell is internal-only for organic posts. See the README boundaries.
|
||||
> API pull; the **in-network/out-of-network reach split** is visible natively too
|
||||
> (Discovery, under impressions — rolling out globally since June 2026) but as
|
||||
> percentages, and is absent from the CSV, with export status unverified; dwell is
|
||||
> internal-only for organic posts. Saves and the reach share are therefore
|
||||
> **optional manual columns** the user adds to the CSV (`Saves`,
|
||||
> `Out-of-network` / `In-network`) — see `assets/analytics/README.md` for the
|
||||
> entry rules, and the README boundaries for the full data boundary.
|
||||
|
||||
## Step 1: Check for CSV Files in Exports Directory
|
||||
|
||||
|
|
@ -135,6 +141,7 @@ The CLI prints (see `cli.ts` `handleImport`):
|
|||
- `Date range:` — earliest to latest post in the batch
|
||||
- `Batch ID:` and `Saved to: posts/<file>` — the batch file written
|
||||
- `Saves entered:` — only when the CSV carried a `Saves` column (manual entry)
|
||||
- `Reach entered:` — only when the CSV carried an `Out-of-network` (or `In-network`) column; reports the impressions-weighted share **and how many posts carried a reading**, so partial coverage is visible instead of reading as a whole-batch number
|
||||
- An anomaly block — either `Immediate alerts detected:` with 🔴/⚠️/ℹ️ spike/drop lines, or `No anomalies detected in imported data.`
|
||||
|
||||
**Surface the CLI's output to the user** — for example:
|
||||
|
|
@ -146,6 +153,7 @@ Posts imported: 42
|
|||
Date range: 2025-12-01 to 2026-01-29
|
||||
Saved to: posts/2025-12-01-batch-a1b2c3d4.json
|
||||
Saves entered: 1,204 across 18 post(s) (manual)
|
||||
Reach entered: 31.4% out-of-network across 12 post(s) (manual, impressions-weighted)
|
||||
|
||||
Immediate alerts detected:
|
||||
─────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ cat ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/weekly-repo
|
|||
The report contains:
|
||||
- **week**: ISO week identifier
|
||||
- **generatedAt**: ISO timestamp when the report was generated
|
||||
- **summary**: Totals and averages — `totalPosts`, `totalImpressions`, `totalReactions`, `totalComments`, `totalShares`, `totalClicks`, optional `totalSaves` (manual entry only), `avgEngagementRate`, `avgImpressionsPerPost`
|
||||
- **summary**: Totals and averages — `totalPosts`, `totalImpressions`, `totalReactions`, `totalComments`, `totalShares`, `totalClicks`, optional `totalSaves` (manual entry only), optional `avgOutOfNetworkPct` (manual entry only — the impressions-weighted out-of-network share), `avgEngagementRate`, `avgImpressionsPerPost`
|
||||
- **topPerformers** / **underperformers**: Best / weakest posts by engagement rate
|
||||
- **trends**: Week-over-week change — `impressionsTrend`, `engagementTrend`, `comparedTo`, `percentChange`
|
||||
- **alerts**: Anomalies and significant events (intra-week + week-over-week)
|
||||
|
|
@ -272,6 +272,24 @@ Format the data into a readable report using this template:
|
|||
**Posts published:** {summary.totalPosts}
|
||||
**Engagement rate:** {summary.avgEngagementRate}%
|
||||
|
||||
### 🌐 Reach Split (only when `summary.avgOutOfNetworkPct` is present)
|
||||
|
||||
| Half | Share of impressions | Reads as |
|
||||
|------|----------------------|----------|
|
||||
| Out-of-network | {summary.avgOutOfNetworkPct}% | Acquisition — the post reached people who do not follow you |
|
||||
| In-network | {100 - summary.avgOutOfNetworkPct}% | Resonance — it landed with the audience you already have |
|
||||
|
||||
State the coverage honestly: the share is impressions-weighted across **only the
|
||||
posts that carried a manual reading**. If the field is absent, say the split is
|
||||
**not entered for this period** — never estimate it, and never print 0%.
|
||||
|
||||
Read the two halves against the week's goal, not as a score:
|
||||
- **Out-of-network up, engagement rate flat/down** — the algorithm distributed it to
|
||||
strangers who did not act. Reach without resonance; the hook traveled, the body did not.
|
||||
- **In-network high, out-of-network low** — deepening, not growing. Fine for a trust play,
|
||||
a problem if the week's goal was audience growth.
|
||||
- **Both up** — the topic has pull beyond your circle. This is the pattern worth repeating.
|
||||
|
||||
### 🏆 Top Performers
|
||||
|
||||
**Most Impressions:**
|
||||
|
|
@ -362,6 +380,17 @@ writeState(content => recordDoNext(content, {
|
|||
"
|
||||
```
|
||||
|
||||
**A reach reading belongs in this channel too.** When `avgOutOfNetworkPct` is present, the
|
||||
split is a directive about *what to write next*, not a number to admire — so persist it here
|
||||
rather than leaving it in the report:
|
||||
|
||||
- Out-of-network high on one topic → `'Write the next post on <topic> — it travels past your network'`, evidence `'weekly 2026-W23: <topic> 58% out-of-network vs 31% weighted average'`
|
||||
- Out-of-network up but engagement rate down → `'Keep the <topic> hook, rebuild the body — strangers saw it and did not act'`
|
||||
- Out-of-network low across the week → `'Next post targets a problem non-followers search for, not an in-circle reference'`
|
||||
|
||||
Never persist a reach directive when the field is absent — an unentered split is unknown, and a
|
||||
directive with no measured evidence pointer is exactly what this channel exists to prevent.
|
||||
|
||||
**Lifetime (do not hand-manage it):** `recordDoNext` replaces this source's own previous rows on
|
||||
every write and drops any row older than 60 days, so the section stays a live steering signal
|
||||
rather than a growing backlog. Directives from other sources (`ab-test`, `analyze`,
|
||||
|
|
@ -370,6 +399,50 @@ rather than a growing backlog. Directives from other sources (`ab-test`, `analyz
|
|||
Then confirm in one line what was persisted, e.g. "2 do-next directives written to state — the
|
||||
next post/newsletter will read them at Step 0."
|
||||
|
||||
## Step 7c: Offer a baseline update to `engagement-patterns.md` (operator-gated)
|
||||
|
||||
The do-next directives above steer the **next** piece. This step maintains the **standing**
|
||||
baseline every other surface reads: `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/engagement-patterns.md`
|
||||
— the file `analytics-interpreter`, `strategy-advisor` and `/linkedin:audit` treat as the user's
|
||||
tracked truth about their own audience. It goes stale silently: nothing in the pipeline writes it,
|
||||
so it keeps asserting last quarter's best time and topics while the reports say otherwise.
|
||||
|
||||
So after a report, **offer a diff** — never write it silently:
|
||||
|
||||
1. **Read the baseline file.** If it is missing or still all `[Day]`/`[Topic]` placeholders, say so
|
||||
and offer to seed it from this report instead of diffing.
|
||||
2. **Compare only what this report actually measured** — best posting windows, top-performing
|
||||
topics, format performance, and (when entered) the reach split. Leave every other section alone.
|
||||
3. **Present each proposed change as old → new with the evidence**, e.g.:
|
||||
|
||||
```
|
||||
Proposed baseline updates (from weekly 2026-W23, n=11 posts):
|
||||
|
||||
1. Primary posting window
|
||||
old: Tuesday at 12:00 — avg 1,200 impressions
|
||||
new: Tuesday at 08:00 — avg 2,050 impressions (4 posts over 3 weeks)
|
||||
|
||||
2. Top-performing topic
|
||||
old: (empty)
|
||||
new: agent orchestration — avg 4.1% engagement, 58% out-of-network (3 posts)
|
||||
|
||||
3. Format performance
|
||||
old: carousel — untracked
|
||||
new: carousel — 3x shares vs text (1 post — thin, flagged as provisional)
|
||||
```
|
||||
|
||||
4. **Ask for an explicit go** with `AskUserQuestion` — options: **Apply all** · **Apply
|
||||
selected** · **Skip**. Skipping is a normal outcome; a thin sample is a reason to skip.
|
||||
5. **Only on an explicit go, edit the file** — change the affected lines and append to that
|
||||
section's `**Update Log:**` with the date and what the number came from. Never rewrite
|
||||
sections the report did not measure, and never delete the user's own annotations.
|
||||
6. If a proposed change rests on **1–2 posts**, label it *provisional* in the diff and say plainly
|
||||
that it is not yet a pattern. Do not let a single post rewrite a baseline.
|
||||
|
||||
Write only to the per-user data dir path above — never to the plugin's shipped seed
|
||||
(`engagement-patterns-template.md` and its placeholder sibling), which is the template
|
||||
the data dir was initialized from, not the user's data.
|
||||
|
||||
## Step 8: Offer Deep Dive Options
|
||||
|
||||
After presenting the report, ask:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue