# Buffer Stats

> Use when user wants Buffer operational + engagement stats across channels — "buffer stats", "how is my buffer doing", "buffer analytics", "buffer queue health", "how did my buffer posts perform", "weekly buffer report", "buffer insights".

- Skill: `michaellady/buffer-stats` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add michaellady/buffer-stats`
- Raw SKILL.md: https://api.skillmd.com/api/skills/michaellady/buffer-stats/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: michaellady (https://skillmd.com/u/michaellady)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/michaellady/buffer-stats

---


# buffer-stats

Combine Buffer's publishing MCP (operational data) with a gstack scrape of Buffer's Analyze dashboard (engagement data) into a one-shot weekly report. Covers queue health, posting goal status, per-channel follower/engagement metrics, and top posts by engagement — plus week-over-week deltas.

Why this exists: Buffer is the fan-out layer for IG/LinkedIn/Facebook/Threads, but engagement on those surfaces is a blind spot in `/flywheel` today. The Buffer MCP alone only covers publishing state (queue, goals, send counts); it has no analytics fields. This skill closes the gap by scraping Analyze the same way `linkedin-stats` scrapes LinkedIn Creator.

## Usage

`/buffer-stats` — full report (operational + engagement across all connected channels) and write a snapshot
`/buffer-stats --no-cache` — print report without writing a snapshot (ad-hoc mid-week checks)
`/buffer-stats --days N` — override the default 7-day window
`/buffer-stats --compare YYYY-MM-DD` — diff against a specific historical snapshot instead of the newest
`/buffer-stats operational` — skip the Analyze scrape; MCP-only fast path

> **Cron / autonomous runs CAN do the full engagement scrape (updated 2026-07-19).** The earlier "operational-only by design" rule is obsolete — three consecutive cron degradations (2026-05-10 cookie expiry, 2026-05-17 cookie picker, 2026-05-24 binary absent) all traced to two fixable causes, and the 2026-07-19 weekly cron completed the FULL Insights + Analyze scrape unattended. The two prerequisites:
> 1. **PATH**: the browse binary spawns `bun`, which isn't on a cron shell's PATH — `export PATH="$HOME/.bun/bin:$PATH"` before any `$B` call (symptom: `Executable not found in $PATH: "bun"` from `startServer`).
> 2. **Cookies**: `$B cookie-import-browser chrome --domain .buffer.com` (dot-prefixed) reads Chrome's cookie store directly — NO interactive picker. Works as long as the user's Chrome profile is logged in to Buffer.
>
> Attempt the full scrape first even when unattended; fall back to the `operational` fast path (and label engagement DEGRADED) only if the cookie import itself fails (i.e. Chrome's own session is dead).

> **⚠️ 2026-08-16 — three state changes from the weekly run:**
> 1. **Channel set is now 4, not 6.** LinkedIn personal (`mikelady`) and Threads personal (`mikelady`) are DISCONNECTED from Buffer (org `limits.channels = 4`). All "6 channels" references below are historical; the Insights per-channel table now renders 4 rows (LI page, FB page, Threads EVC, IG business — plus 2 locked startPage channels to exclude). The posts-only ROI fallback path now applies only to Threads EVC. LinkedIn-personal numbers must come from `/linkedin-stats`, not Buffer.
> 2. **The Buffer MCP token lacks `insights:read` scope** — `list_posts`/`get_post` with `includeMetrics: true` returns 403 ("Insufficient scope. Granted: posts:read, posts:write, ideas:read, ideas:write, account:read, account:write"). Per-post metrics must come from the Insights/Analyze scrape until the token is re-issued with `insights:read`.
> 3. **Insights date-picker drift:** the default landed on a 30-day range ("Jul 18 - Aug 16"), and the presets read `7 days / 30 days / Month to date / Last month / Custom` (no "Last …" prefix). Click the `7 days` button explicitly before extracting the weekly window; the Summary line ("Aug 10 - 16, 2026 · Compared to Aug 3 - 9, 2026") confirms the active range.

## 🟢 Happy Path (read first; everything below is edge-case detail)

For a weekly Buffer stats run when nothing goes wrong. ~2-4 min wall-clock. Each step links to a labeled edge case (`Edge: <name>`) you only need to read if that step fails.

**Step 1 — Load config (5 sec).** Read `config.local.json` if present, else `config.json`. Pull `organization_name`, `default_window_days` (7), `top_posts_limit` (10), `analyze_base_url`, `insights_base_url`. Set `DAYS=7`.

**Step 2 — Operational data via Buffer MCP (15-30 sec).**
- `mcp__buffer__get_account` → pick org by `config.organization_name` (or the only one). See `Edge: multi-org-selection`.
- `mcp__buffer__list_channels` → filter out `isDisconnected: true`, apply `channels_include`/`channels_exclude`.
- Per channel: `mcp__buffer__get_channel` for posting goal, schedule, paused flag. `mcp__buffer__list_posts` twice — `status: [scheduled]` for queue depth, `status: [sent]` with `createdAt.start` = N-days-ago for sent count and tag distribution.

**Step 3 — Engagement scrape: publish.buffer.com/insights (30-60 sec).** `$B goto https://publish.buffer.com/insights`, `sleep 4`, extract Posts/Followers/Reactions/Comments summary + top-5 posts via the regex blocks in Phase 2a. Default range is "Last 7 days" — leave it alone. **Also extract the per-channel aggregate rows** (each connected channel has a Posts/Reactions/Comments row in the Insights view — all 6 channels appear, including the 3 Buffer Analyze can't reach: LinkedIn personal + both Threads accounts). See **Phase 2a-bis** below for the per-channel-row extractor; this is the source of truth for `channel_roi[]` entries that don't have impressions. See `Edge: insights-row-extraction-failed`.

**Step 4 — Engagement scrape: analyze.buffer.com per-channel (60-90 sec).** `$B goto $ANALYZE_URL` (analyze home page lists channels via `a[href*=/overview/]` links — extract channelId from each). Cookies for `buffer.com` carry to `analyze.buffer.com` (see `Edge: subdomain-cookie-gap`). For each channel: `$B goto /<service>/overview/<channelId>` → click the "Last week" preset button directly (the picker is already expanded inline — NO popover; see `Edge: analyze-picker-already-inline`) → `sleep 4` → extract `<li>` metrics where each `<li>` has text shape `"Label\nValue\nDelta%"` (see `Edge: analyze-hashed-classes`).

**Step 4b — Per-channel Channel ROI (5 sec).** Two paths now (added 2026-05-19, task #377):

- **Impressions path** (Analyze-covered: LinkedIn pages, IG business, FB pages where impressions exist). Compute `channel_roi_score = (avg_imps_per_post * eng_rate_decimal * 100) / (sent_count + 1)`, bucket 🟢 ≥100 / 🟡 10-100 / 🔴 <10 & followers<50 / ⚪ <10 & followers≥50, emit `engagement_denominator: "impressions"`. For Instagram, compute eng rate as `(likes + comments) / impressions × 100` (Buffer doesn't expose it directly).
- **Posts-only fallback path** (Insights-covered but no impressions: LinkedIn personal, Threads × 2). Compute `channel_roi_score = (reactions + 2 * comments) / posts` from the Insights row, bucket via the posts-only rubric in Phase 2c, emit `engagement_denominator: "posts"`. Comments are weighted 2× because they're a stronger intent signal than reactions and roughly 10× rarer at our volume.

See `Edge: facebook-impressions-unavailable` (no path applies — `channel_roi_score: null`, `bucket: "data_unavailable"`). See **Phase 2c** below for the full rubric, posts-only verdict variants, and worked examples.

**Step 5 — Compose snapshot JSON (5 sec).** Build the object with `engagement_tracked_channels` (any channel with engagement data via Insights OR Analyze — typically all 6) AND `posting_channels` (full set) as separate counts — never collapse them (see `Edge: engagement-vs-posting-channel-conflation`). Channels with no engagement data at all (rare — usually only happens when both surfaces fail) land in `channels_engagement_unavailable[]`. Include a `channel_roi[]` array with `{channel, sent, posts, reactions, comments, avg_imps_per_post, eng_rate_pct, channel_roi_score, engagement_denominator, bucket, verdict}` per channel — `null` for channels with missing inputs. `engagement_denominator` is `"impressions"` for the Analyze path and `"posts"` for the Insights fallback path.

**Step 5.5 — JOIN tagIds → format_tag, emit `format_engagement` (5 sec).** For every post in `top_posts[]` and every sent post enumerated in Step 2, read `tags { id name }` from the MCP payload and resolve against `_shared/buffer-post-prep/tag-ids.local.json` (inverted: tagId → `format:<name>`). Write `top_posts[].format_tag` (the **real** tag) alongside the existing `format_tag_guess` (kept as fallback). Roll up into a top-level `format_engagement` object keyed by `format:<name>` with `{posts, reactions, impressions, eng_rate_pct, channels{}}`. See **Phase 3.5** below. Edge cases: `Edge: tag-ids-missing` (no lookup file, every post resolves to `null`), `Edge: post-has-no-format-tag` (older or manual posts — fall back to `format_tag_guess` for display, exclude from aggregate).

**Step 6 — Week-over-week deltas (2 sec).** Find newest snapshot in `cache/` older than `delta_window_days` (7). Compute Δfollowers / Δengagement_rate / Δqueue_depth / Δsent_count per channel. First run renders `—` (see `Edge: delta-bootstrap`).

**Step 7 — Format-performance attribution (10 sec).** For each sent post in window, read its `format:<name>` tag via `mcp__buffer__get_post`. Aggregate by `(channel, format)` → avg impressions, avg engagement, eng rate. Surface a verdict per channel.

**Step 8 — Skill recommendations (5 sec).** From the format-performance table, generate suggestions for promote-*, carousel-newsletter, etc. with data citations. Suggestions only — user accepts/rejects manually.

**Step 9 — Render + write (5 sec).** Print the fixed-structure markdown report (Channels table → Format performance → Skill recommendations → Top posts → Flags). Write `cache/snapshot-YYYY-MM-DD.json` and `.md` unless `--no-cache`.

### Edge labels (jump to these only when you hit the matching failure signal)

| Label | Symptom |
|---|---|
| `Edge: multi-org-selection` | Account has >1 Buffer org and no `organization_name` saved |
| `Edge: subdomain-cookie-gap` | `analyze.buffer.com` redirects to login despite `buffer.com` cookies |
| `Edge: no-last-7-days-preset` | Buffer Analyze date picker has no "Last 7 days" option |
| `Edge: analyze-picker-already-inline` | Date picker shows This/Last month + This/Last week buttons inline — no popover; click target directly |
| `Edge: analyze-hashed-classes` | Class selectors return nothing; text-pattern extractors needed |
| `Edge: instagram-not-linked` | IG channel shows "Unlock Instagram Analytics" banner, no engagement data |
| `Edge: instagram-no-eng-rate-field` | IG doesn't report "Engagement Rate" — compute from `(likes + comments) / impressions × 100` |
| `Edge: facebook-impressions-unavailable` | Facebook Pages Impressions field missing entirely |
| `Edge: engagement-data-lag` | New channel / freshly published post engagement reads `null` 24-48h |
| `Edge: custom-date-range-unimplemented` | `--days` value other than 7 or ~30 |
| `Edge: delta-bootstrap` | First run — no prior snapshot to diff against |
| `Edge: engagement-vs-posting-channel-conflation` | total_followers reads tiny because LinkedIn-personal/Threads omitted |
| `Edge: tag-ids-missing` | `_shared/buffer-post-prep/tag-ids.local.json` not present — `format_tag` resolves to `null` for every post |
| `Edge: post-has-no-format-tag` | Pre-convention or manual post — no `format:*` tag, falls back to `format_tag_guess` for display only |
| `Edge: insights-row-extraction-failed` | Per-channel rows in `publish.buffer.com/insights` don't parse — selectors drifted, all 3 non-Analyze channels read null |

Each label corresponds to an entry in **Known issues / robustness notes** below.

## Config

The skill reads config from (in priority order):
1. `~/dev/claude-social-media-skills/buffer-stats/config.local.json` (gitignored — personal org/channel overrides)
2. `~/dev/claude-social-media-skills/buffer-stats/config.json` (committed defaults)

Fields:
- `organization_name` — if your account has >1 Buffer organization, set this to auto-pick. `null` = prompt on first run, then save choice.
- `channels_include` — array of channel names to report on. `null` = all connected channels.
- `channels_exclude` — array of channel names to skip (e.g. `["X"]` if you've stopped posting there).
- `default_window_days` — default reporting window (7).
- `top_posts_limit` — how many top posts to show (10).
- `delta_window_days` — compare against the newest snapshot at least this old (7).
- `stale_snapshot_days` — flag snapshots older than this when `/flywheel` consumes them (14).
- `analyze_base_url` / `publish_base_url` — Buffer surface URLs (unlikely to change).
- `insights_base_url` — `https://publish.buffer.com/insights` (the Beta cross-channel summary view; richer than analyze.buffer.com for most "how have my posts done" questions).

Load config at the start of every run:

```bash
CONFIG_DIR=~/dev/claude-social-media-skills/buffer-stats
if [ -f "$CONFIG_DIR/config.local.json" ]; then CONFIG_FILE="$CONFIG_DIR/config.local.json"; else CONFIG_FILE="$CONFIG_DIR/config.json"; fi
DAYS=$(jq -r .default_window_days "$CONFIG_FILE")
TOP_N=$(jq -r .top_posts_limit "$CONFIG_FILE")
ORG_NAME=$(jq -r .organization_name "$CONFIG_FILE")
```

## Process

### Phase 1 — Operational data (via Buffer MCP)

The Buffer MCP is always available in this project — no browser needed, no cookies required.

1. **Resolve organization**
   - Call `mcp__buffer__get_account` → read `organizations[]`.
   - If `config.organization_name` is set, find by name. Else if there's exactly one org, use it. Else prompt the user to pick and offer to save the choice to `config.local.json`.

2. **List channels**
   - Call `mcp__buffer__list_channels` with `organizationId`.
   - Filter out `isDisconnected: true`.
   - Apply `channels_include` / `channels_exclude` filters from config.

3. **Per-channel operational metrics**
   - For each channel, call `mcp__buffer__get_channel` to grab:
     - `postingGoal` — `{ goal, sentCount, scheduledCount, status, periodStart, periodEnd }` (may be `null` if no goal set)
     - `postingSchedule` — weekly cadence array
     - `isQueuePaused`
     - `service` + `type` (e.g. `linkedin/profile`, `instagram/business`)
   - For each channel, call `mcp__buffer__list_posts` with:
     - `filter.channelIds: [channelId]`, `filter.status: [scheduled]`, `first: 100` → queue depth
     - `filter.channelIds: [channelId]`, `filter.status: [sent]`, `filter.createdAt: { start: <N days ago> }`, `first: 100` → sent count in window, plus tag distribution

4. **Organization-level flags**
   - Paused queues, posting goals `AtRisk`, channels with empty queues, channels over weekly limit.

### Phase 2 — Engagement data

Skip this phase entirely when invoked as `/buffer-stats operational`.

**Buffer exposes engagement data on TWO surfaces.** The skill scrapes both for complementary views (confirmed 2026-04-27):

1. **`https://publish.buffer.com/insights`** (Beta) — **cross-channel aggregated.** Covers ALL connected channels (LinkedIn personal, both Threads accounts, etc.) that analyze.buffer.com doesn't surface. Top posts ranked by reactions/comments. Best single view for "how have my posts done overall."
2. **`https://analyze.buffer.com`** — per-channel deep dive. Covers FB pages, IG business, LinkedIn pages, Twitter (NOT LinkedIn personal, NOT Threads). Top posts ranked by impressions. Best for per-channel engagement-rate breakdown.

Run Insights first (faster, better cross-channel summary), then Analyze for the channels Insights doesn't fully break out per-platform engagement-rate for.

#### Phase 2a — publish.buffer.com/insights (cross-channel summary)

```bash
B=~/.claude/skills/gstack/browse/dist/browse
$B goto https://publish.buffer.com/insights
sleep 4
$B js "
const main = document.querySelector('main, [role=main]') || document.body;
const text = main.innerText;
// Top section: Posts / Followers / Reactions / Comments with deltas
const summary = {};
const summaryRegex = /(Posts|Followers|Reactions|Comments)\\s+(\\S+)\\s+(Up|Down)\\s+([\\d.]+%|\\+\\d+)/g;
let m;
while ((m = summaryRegex.exec(text))) {
  summary[m[1]] = { value: m[2], direction: m[3].toLowerCase(), delta: m[4] };
}
({ summary, mainTextSnippet: text.slice(0, 600) })
"
```

Date presets at publish.buffer.com/insights: **Last 7 days, Last 30 days, Month to date, Last month, Custom**. Default is "Last 7 days" — change via the picker button (text matches the current selection, e.g. "Last 7 days" → click → option list appears).

Top-5-posts extraction from Insights:
```bash
$B js "
const text = (document.querySelector('main, [role=main]') || document.body).innerText;
// Posts appear as '#N Post' / 'X Reactions' / time / text
const postBlockRegex = /#(\\d+) Post\\s+(\\d+) Reactions\\s+(\\d{1,2}:\\d{2}\\s*[AP]M)\\s+([\\s\\S]+?)(?=View comments|Duplicate post|#\\d+ Post|$)/g;
const posts = [];
let m;
while ((m = postBlockRegex.exec(text))) {
  posts.push({ rank: parseInt(m[1]), reactions: parseInt(m[2]), time: m[3], snippet: m[4].trim().slice(0, 200) });
}
posts
"
```

The Insights surface is in **Beta** (banner: "Looking for more metrics and reports? Go to Analyze"). Treat it as the primary cross-channel view but expect occasional UI changes.

##### Phase 2a-bis — Per-channel aggregate rows from Insights (added 2026-05-19, task #377)

`publish.buffer.com/insights` renders a per-channel breakdown below the summary tiles — **one row per connected channel, all 6 visible**, regardless of whether Buffer Analyze covers that surface. This is the only place we can read aggregate engagement for LinkedIn personal + both Threads accounts (Analyze doesn't expose them per-channel).

Each row's text shape is `"<Channel display name>\n<service>\n<N> Posts\n<R> Reactions\n<C> Comments"` (no impressions field — Insights aggregates reactions/comments only, which is exactly why these channels fall back to the posts-only ROI path).

```bash
$B js "
const main = document.querySelector('main, [role=main]') || document.body;
const text = main.innerText;
// Per-channel rows: name on one line, then Posts/Reactions/Comments triplets nearby.
// Capture is greedy on the name then anchors on the integer-Posts/Reactions/Comments triple.
const rowRegex = /([^\\n]+?)\\n(linkedin|instagram|facebook|threads|twitter)\\s*\\n(\\d+)\\s+Posts?\\s+(\\d+)\\s+Reactions?\\s+(\\d+)\\s+Comments?/gi;
const rows = [];
let m;
while ((m = rowRegex.exec(text))) {
  rows.push({
    display_name: m[1].trim(),
    service: m[2].toLowerCase(),
    posts: parseInt(m[3]),
    reactions: parseInt(m[4]),
    comments: parseInt(m[5])
  });
}
rows
" > /tmp/bf-insights-rows.json
```

Cross-reference each row against `mcp__buffer__list_channels` output by display name to attach the `channelId` + `type` (profile vs page vs business). If the row regex returns fewer rows than `posting_channels`, that's `Edge: insights-row-extraction-failed` — re-run discovery (see "Re-discovery" section).

The default range at `publish.buffer.com/insights` is "Last 7 days" but the per-channel rows respect whatever the picker is set to. For the weekly snapshot, leave it at "Last 7 days". For a 30-day reconciliation (when comparing against the user's manual eyeball of Insights), click "Last 30 days" first.

> **⚠️ 2026-05-31 — the per-channel breakdown is now a "Performance" TABLE, not text rows. The `rowRegex` above returns `[]`.** Buffer moved the breakdown into a `<table>` (header `Channel | Posts | Reactions | Comments | Eng. Rate`) lower on the page, between the `Performance` heading and the `Metrics` heading. Each cell carries `value\nUp/Down\npct%`. Use the table extractor below; keep the `rowRegex` as a fallback in case Buffer reverts.
>
> ```bash
> $B js "
> const tbl=[...document.querySelectorAll('table')].find(t=>/Channel/i.test((t.innerText||'').slice(0,40)));
> const rows=[...(tbl?tbl.querySelectorAll('tr'):[])].map(r=>{
>   const cells=[...r.querySelectorAll('td,th')].map(c=>c.innerText.trim());
>   if(cells.length<4) return null;
>   const name=cells[0].split('\n')[0];
>   const num=c=>{const m=c.match(/-?[\d,.]+/);return m?+m[0].replace(/,/g,''):null;};
>   return {display_name:name, posts:num(cells[1]), reactions:num(cells[2]), comments:num(cells[3]), eng_rate:num(cells[4])};
> }).filter(r=>r&&r.posts!==null);
> rows
> "
> ```
>
> **Disambiguating duplicate display names.** The table shows Buffer's `name` field, so all three EVC channels read `enterprisevibecode` and both personal channels read `mikelady`. Resolve by: (a) `facebook/page` is the only `Enterprise Vibe Code` (capitalized); (b) cross-reference the 3 Analyze channelIds (FB/LI-page/IG appear at `analyze.buffer.com` home as `a[href*=/overview/]`) against post counts to pin LI-page vs IG vs Threads-EVC; (c) for the two `mikelady` rows, the one with comments > 0 is `linkedin/profile` (the engagement leader), the comment-less one is `threads/profile`. Confirm IG by matching its Analyze "Posts" count to the table row.
>
> **⚠️ The Insights table UNDERCOUNTS Facebook reactions** (reports FB `Reactions` as `0`). Buffer Analyze's FB-specific overview is authoritative — on 2026-05-31 it showed **65 reactions + 20 new fans** where Insights said 0. **Always read Facebook engagement from Analyze, not the Insights table.** Don't bucket FB as a dead channel off the Insights `0` — check Analyze first.

#### Phase 2b — analyze.buffer.com (per-channel deep dive)

**Buffer Analyze lives at `https://analyze.buffer.com`** (separate subdomain from `publish.buffer.com`). The dashboard is client-rendered React; selectors below are confirmed as of 2026-04-20.

#### Step 2a — Initialize browser and verify login

```bash
B=~/.claude/skills/gstack/browse/dist/browse
if [ ! -x "$B" ]; then echo "gstack browse not installed"; exit 1; fi

# 🔧 Two gstack gotchas that silently blocked the 2026-06-19 refresh — handle FIRST:
# 1. WEDGED session: if any `$B` page command returns "No active page" despite a
#    running server, run `_shared/gstack-recover/gstack-recover --probe-url <url>`
#    (go build it if absent) — it hard-restarts the bun server + Chrome-for-Testing.
# 2. Cookie import needs the DOT-PREFIXED domain: `$B cookie-import-browser chrome
#    --domain .buffer.com` (bare `buffer.com` imports 0). `--domain` reads Chrome's
#    store directly = NO interactive picker. `_shared/gstack_auth.sh buffer.com <url>`
#    does both (recover + dot-prefixed import) and exits 0 when logged in.

ANALYZE_URL=$(jq -r .analyze_base_url "$CONFIG_FILE")
$B goto "$ANALYZE_URL"
sleep 3
$B js "(() => ({ url: location.href, needLogin: !!document.querySelector('input[type=email], input[type=password]') }))();" > /tmp/bf-login-check.txt
if grep -q '"needLogin":true' /tmp/bf-login-check.txt; then
  echo "Not logged in — attempting cookie import..."
  $B cookie-import-browser chrome buffer.com
  # Cookie picker UI opens; user selects buffer.com, closes picker
  $B goto "$ANALYZE_URL"
  sleep 3
  $B js "(() => !!document.querySelector('input[type=password]'))();" > /tmp/bf-login-check.txt
  if grep -q 'true' /tmp/bf-login-check.txt; then
    # KNOWN ISSUE: cookies for `buffer.com` do not cover the `analyze.` subdomain.
    # A fresh login is required on first run.
    $B handoff "Please log in to Buffer Analyze at $ANALYZE_URL — reply 'done' once you see the dashboard."
    # user runs $B resume
  fi
fi
```

#### Step 2b — Discover channel URLs from the home page

Each channel has a dedicated URL. Extract them from the home-page links:

```bash
$B goto "$ANALYZE_URL"
sleep 2
$B js "
  (() => {
    const links = [...document.querySelectorAll('a[href]')].filter(a => /analyze\\.buffer\\.com\\/(facebook|instagram|twitter|linkedin|threads)\\/overview\\//.test(a.href));
    return links.map(a => {
      const m = a.href.match(/\\/(facebook|instagram|twitter|linkedin|threads)\\/overview\\/([a-f0-9]+)/);
      return { service: m[1], channelId: m[2], name: a.innerText.trim(), url: a.href };
    });
  })();
" > /tmp/bf-channels.json
```

Cross-reference with the MCP's `list_channels` output (matches on `serviceId` → Buffer's channel ID).

#### Step 2c — Set the date range

Buffer Analyze has preset ranges only: **This month, Last month, This week, Last week, Custom**. There's no "Last 7 days" preset — **use "Last week"** as the closest match for a 7-day window (it's Monday-to-Sunday of the previous week, a complete 7 days of data).

**Why not "This week":** it starts at Monday-to-now; if the skill runs on a Monday, all metrics read 0.

```bash
# Open the date picker and pick "Last week"
$B js "
  (() => {
    const btn = [...document.querySelectorAll('button')].find(b => /This month|Last month|This week|Last week|Custom|[A-Z][a-z]+ \\d+(\\s*-\\s*\\d+)?,?\\s*\\d{4}/.test(b.innerText));
    btn?.click();
    return btn ? 'opened' : 'no_btn';
  })();
"
sleep 1
$B js "
  (() => {
    const opt = [...document.querySelectorAll('button')].find(b => b.innerText?.trim() === 'Last week');
    opt?.click();
    return opt ? 'selected' : 'no_opt';
  })();
"
sleep 3
```

For `--days 30`, use "Last month" (full calendar previous month). For arbitrary windows, use "Custom" — see Known Issues section.

#### Step 2d — Extract channel Overview metrics

Each metric on a channel's Overview page is a `<li>` whose `innerText` matches `"<Label>\n<Value>\n<Delta>%"` (delta is optional). The class names are hashed (styled-components), so extract by text pattern rather than class selector:

```bash
# For each channel URL discovered in Step 2b:
$B goto "$CHANNEL_URL"
sleep 3
# Re-set date range if needed (the picker persists across navigations)
$B js "
  (() => {
    const lis = [...document.querySelectorAll('li')];
    const metrics = {};
    for (const li of lis) {
      const lines = (li.innerText||'').split('\\n').map(s => s.trim()).filter(Boolean);
      if (lines.length < 2 || lines.length > 3) continue;
      const [label, value, delta] = lines;
      if (!/^[A-Za-z][A-Za-z ]+\$/.test(label)) continue;
      if (!/^[\\d.,]+%?\$/.test(value)) continue;
      metrics[label] = { value, delta: delta || null };
    }
    return metrics;
  })();
"
```

Expected labels per service (confirmed for LinkedIn, assumed similar for others):
- **Performance section:** `Followers`, `New Followers`, `Posts`, `Impressions`, `Clicks`, `Engagement Rate`
- **Average performance section:** `Average Impressions Per Post`, `Average Clicks Per Post`, `Average Engagement Rate Per Post`

**Service-specific gaps:**
- Facebook Pages: `Impressions` shows a "not available" banner on some plans — parse as `null`.
- Instagram: requires linking to a Facebook Business Page (banner: "Unlock Instagram Analytics"). If the banner is present, skip engagement extraction and flag in output.

#### Step 2e — Extract per-post metrics from the Posts tab

Navigate to `https://analyze.buffer.com/<service>/posts/<channelId>`. Posts are in an `<ol>` containing `<li>` elements with the semantic class token `post-item` (safer than the hashed prefix):

```bash
$B goto "https://analyze.buffer.com/$SERVICE/posts/$CHANNEL_ID"
sleep 3
$B js "
  (() => {
    const posts = [...document.querySelectorAll('li.post-item, li[class*=post-item]')];
    return posts.map(post => {
      const lines = (post.innerText||'').split('\\n').map(s => s.trim()).filter(Boolean);
      const dateIdx = lines.findIndex(l => /^[A-Z][a-z]+ \\d+, \\d{4}/.test(l));
      const metricsStart = lines.findIndex(l => l === 'Impressions');
      const date = dateIdx >= 0 ? lines[dateIdx] : null;
      const username = dateIdx >= 0 ? lines[dateIdx + 1] : null;
      const postText = dateIdx >= 0 && metricsStart > dateIdx ? lines.slice(dateIdx + 2, metricsStart).join(' ') : null;
      const metrics = {};
      for (let i = metricsStart; i < lines.length - 1; i += 2) {
        if (/^[A-Za-z][A-Za-z. ]+\$/.test(lines[i]) && /^[\\d.,]+%?\$/.test(lines[i+1])) {
          metrics[lines[i]] = lines[i+1];
        }
      }
      return { date, username, postText: postText?.slice(0, 300), metrics };
    });
  })();
" > /tmp/bf-posts-$SERVICE.json
```

Per-post metrics observed for LinkedIn: `Impressions`, `Likes`, `Comments`, `Clicks`, `Eng. Rate`. Facebook and Threads likely use `Reactions` or `Shares` in place of `Likes` — the extractor is label-agnostic and will preserve whatever Buffer shows.

#### Step 2f — Cross-channel aggregation

Combine Step 2d (per-channel overview) + Step 2e (per-channel posts). Compute:
- `channels[].engagement` = overview metrics (followers, engagement rate, impressions)
- `top_posts[]` = flatten per-service posts, sort by `Impressions` desc, take top N from `config.top_posts_limit`

Normalize metric names across services (e.g. LinkedIn's "Eng. Rate" and Facebook's potentially different label — keep originals in raw JSON but expose a unified `engagement_rate` field).

#### Phase 2c — Channel ROI score (run after Phase 2a-bis + Phase 2b for every channel with engagement data)

For each Buffer-connected channel, compute a `channel_roi_score` and bucket it. This surfaces deprioritization candidates so the user can stop fanning out content to channels that don't earn back the queue cost. **Two paths** depending on whether impressions data exists:

##### Path A: impressions available (LinkedIn pages, IG business, FB pages with impressions)

```
channel_roi_score = (avg_imps_per_post * eng_rate_decimal * 100) / (sent_count_in_window + 1)
engagement_denominator = "impressions"
```

Where `eng_rate_decimal` is the engagement rate expressed as a decimal (e.g. 12.42% → `0.1242`). The `* 100` keeps the score in a readable 0-1000 range; the `+ 1` in the denominator is a smoothing term so a channel with `sent=0` doesn't divide-by-zero.

**Bucketing rubric (Path A):**

| Score | Followers | Bucket | Action |
|---|---|---|---|
| ≥ 100 | any | 🟢 **High ROI** | Keep current cadence, consider increasing |
| 10 - 100 | any | 🟡 **Mid ROI** | Current cadence is fine |
| < 10 | < 50 | 🔴 **Below threshold** | Drop from fan-out (recommend `min_followers_to_promote = 100`) |
| < 10 | ≥ 50 | ⚪ **Diminishing returns** | Reduce fan-out volume; consider `tease-newsletter` over `promote-newsletter` |

##### Path B: posts-only fallback (LinkedIn personal, Threads × 2 — Insights rows from Phase 2a-bis)

When Buffer doesn't surface impressions for a channel (true for LinkedIn personal and both Threads accounts on Insights), score on the reaction+comment volume per post instead. Comments weighted 2× because they're rarer and a stronger intent signal at our volume (~10× rarer than reactions on the channels we measure).

```
channel_roi_score = (reactions + 2 * comments) / posts
engagement_denominator = "posts"
```

For `posts = 0`, emit `channel_roi_score: null` and bucket as `data_unavailable` — we have no signal at all.

**Bucketing rubric (Path B):**

| Score (engagement per post) | Bucket | Action |
|---|---|---|
| ≥ 1.0 | 🟢 **High eng/post** | Keep current cadence — every post is earning a reaction or comment |
| 0.3 - 1.0 | 🟡 **Mid eng/post** | Current cadence fine; watch for downward drift |
| 0.05 - 0.3 | ⚪ **Low eng/post** | Reduce fan-out frequency; consider routing through a different format |
| 0 - 0.05 | 🔴 **Dead channel candidate** | Investigate (was the channel ever live? auth broken? algorithm shadow-band?). Pair with `audit-buffer-queue`. |
| exactly 0 over ≥10 posts | 🔴 **Dead channel** | Pause queue; open investigation task (see #379 for the enterprisevibecode Threads case) |

**Verdict variants (Path B):**

| Bucket | Verdict template |
|---|---|
| 🟢 High eng/post | `"High engagement per post (X.X reactions+2comments/post across N posts) — keep cadence"` |
| 🟡 Mid eng/post | `"Mid engagement per post (X.X/post) — current cadence is fine"` |
| ⚪ Low eng/post | `"Low engagement per post (X.X/post across N posts) — consider reducing fan-out"` |
| 🔴 Dead candidate | `"Near-dead channel: X total reactions across N posts (X.XX/post) — investigate"` |
| 🔴 Dead channel | `"Dead channel: 0 reactions across N posts — pause and investigate (task #379-style)"` |
| ⚪ data_unavailable | `"No posts in window — no signal to compute ROI"` |

**Worked examples** (confirmed against real data — impressions path 2026-05-18, posts-only path 2026-05-19 from Apr 19-May 19 Insights view):

Path A (impressions):

| Channel | Sent | Avg imps/post | Eng rate | Score | Bucket |
|---|---:|---:|---:|---:|:---|
| LinkedIn page (EVC) | 5 | 13 | 12.42% | `(13 * 0.1242 * 100) / 6 = 26.9` | 🟡 Mid (saved from 🔴 by strong eng rate on tiny audience) |
| Instagram (EVC) | 6 | 157.2 | 1.06%* | `(157.2 * 0.0106 * 100) / 7 = 23.8` | 🟡 Mid |
| Facebook (EVC) | 1 | n/a | n/a | n/a | ⚪ Data unavailable (see `Edge: facebook-impressions-unavailable`) |

\* IG eng rate computed via the fallback below (Buffer doesn't expose a single "Engagement Rate" field for IG).

Path B (posts-only, from Insights 30-day window):

| Channel | Posts | Reactions | Comments | Score `(R + 2C) / P` | Bucket |
|---|---:|---:|---:|---:|:---|
| LinkedIn personal (mikelady) | 60 | 47 | 22 | `(47 + 44) / 60 = 1.52` | 🟢 High eng/post — engagement king |
| Threads (mikelady) | 53 | 11 | 2 | `(11 + 4) / 53 = 0.28` | ⚪ Low eng/post |
| Threads (enterprisevibecode) | 64 | 0 | 0 | `0 / 64 = 0` | 🔴 Dead channel (task #379) |

Cross-method sanity check: LinkedIn personal scoring 1.52 on Path B vs LinkedIn page scoring 26.9 on Path A is **expected** — the two scales are not comparable. Path A is `imps * eng_rate / sent` (impressions × rate); Path B is raw engagement per post. The buckets normalize that gap; the raw scores do not.

**Instagram engagement-rate fallback** (`Edge: instagram-no-eng-rate-field`):

Buffer Analyze's IG overview returns `Posts / Impressions / Reach / Likes / Comments / Daily average impressions / Average likes per post / Average comments per post` — no aggregated "Engagement Rate" line. Compute it:

```
eng_rate_pct = ((likes + comments) / impressions) * 100
```

Use impressions (not reach) as the denominator — keeps the rate comparable to LinkedIn's `engagement_rate` field which is `(reactions + comments + reposts) / impressions`.

**Facebook impressions gap** (`Edge: facebook-impressions-unavailable`): Buffer shows reactions and new fans but the Impressions field is missing entirely. Without impressions you cannot compute either `avg_imps_per_post` or `eng_rate`. Set `channel_roi_score: null`, `bucket: "data_unavailable"`, and note in the verdict.

**Output shape** to include in the snapshot under `channel_roi[]`. Every record carries `engagement_denominator` so downstream consumers know which formula produced the score and which scale to compare against:

```json
"channel_roi": [
  {
    "channel": "linkedin/page (EVC)",
    "followers": 28,
    "sent": 5,
    "avg_imps_per_post": 13,
    "eng_rate_pct": 12.42,
    "channel_roi_score": 26.9,
    "engagement_denominator": "impressions",
    "bucket": "yellow_mid",
    "verdict": "Mid ROI — current cadence fine, but absolute reach is tiny (13 imps/post)"
  },
  {
    "channel": "linkedin/profile (mikelady)",
    "posts": 60,
    "reactions": 47,
    "comments": 22,
    "channel_roi_score": 1.52,
    "engagement_denominator": "posts",
    "engagement_source": "publish.buffer.com/insights per-channel row (30d window)",
    "bucket": "green_high_eng_per_post",
    "verdict": "High engagement per post (1.52 reactions+2comments/post across 60 posts) — keep cadence"
  },
  {
    "channel": "threads/profile (mikelady)",
    "posts": 53,
    "reactions": 11,
    "comments": 2,
    "channel_roi_score": 0.28,
    "engagement_denominator": "posts",
    "engagement_source": "publish.buffer.com/insights per-channel row (30d window)",
    "bucket": "white_low_eng_per_post",
    "verdict": "Low engagement per post (0.28/post across 53 posts) — consider reducing fan-out"
  },
  {
    "channel": "threads/profile (enterprisevibecode)",
    "posts": 64,
    "reactions": 0,
    "comments": 0,
    "channel_roi_score": 0,
    "engagement_denominator": "posts",
    "engagement_source": "publish.buffer.com/insights per-channel row (30d window)",
    "bucket": "red_dead_channel",
    "verdict": "Dead channel: 0 reactions across 64 posts — pause and investigate (task #379)"
  },
  {
    "channel": "facebook/page (EVC)",
    "sent": 1,
    "channel_roi_score": null,
    "engagement_denominator": null,
    "bucket": "data_unavailable",
    "verdict": "FB Impressions field unavailable; manual eyeball only"
  }
]
```

### Phase 3 — Compose snapshot

Combine operational + engagement data into a single JSON object. **CRITICAL schema rule (added 2026-05-03 after the flywheel reported total_followers=26 when the actual was ~2,200):** distinguish "channels we have engagement data for" (the engagement-tracked subset — typically all 6 since Insights covers everything Analyze doesn't) from "channels we post to" (the full posting set). Conflating the two gives downstream consumers like `/flywheel` a false total.

> **Emit a top-level `recent_posts[]` for opus derivatives (added 2026-05-31, #371).** The content-attribution JOIN's `bufferMatch` resolves IG/FB/LinkedIn-page opus-clip derivatives by reading a top-level `recent_posts[]` in this snapshot — the SAME shape tiktok-stats/threads-stats write, plus a `channel` field. Each entry: `{ "channel": "<service>/<type>", "caption": "...[opus:<id>]", "source_tag": {"scheme":"opus","id":"<id>"}, "engagement": {"impressions":N,"reach":N,...} }`. The `channel` field (e.g. `instagram/business`, `facebook/page`, `linkedin/page`) is **required** — the matcher filters on it so the same clip posted to IG + FB + LI-page doesn't triple-count. Populate it from the Phase 2e per-post Analyze scrape: for each per-post record whose text/caption matches an opus clip (text-match against the opus-clip captions, since the Analyze posts tab strips the `[opus:]` footer), emit a `recent_posts[]` entry with that channel + the matched `source_tag.id`. **Constraint:** Buffer Analyze's posts tab paginates/virtualizes — `window.scrollTo` does NOT load past the first ~5 posts per channel, so each run only captures the opus clips on the first page. Full per-clip coverage needs Analyze pagination handling (or reading the opus-clips post-manifest's per-post `scheduleId` + a future per-post analytics endpoint). Until then, emit what the first page yields + note partial coverage. Old snapshots without `recent_posts[]` stay `pending #371` (the matcher falls back gracefully).

**Updated 2026-05-19 (task #377):** the `channels_engagement_unavailable[]` list is for channels with **zero** engagement coverage across BOTH Insights and Analyze. The previous framing — "Buffer does not cover Threads / LinkedIn personal" — was wrong. The correct framing is: Buffer Analyze does not cover those per-post, but Buffer Insights covers their aggregate (posts + reactions + comments per channel, 7d/30d window). Channels that only have Insights aggregate (no per-post breakdown) should still appear in `channel_roi[]` via the posts-only fallback path; they should NOT appear in `channels_engagement_unavailable[]`.

```json
{
  "fetched_at": "2026-04-20T17:00:00Z",
  "window_days": 7,
  "organization": { "id": "...", "name": "..." },
  "engagement_tracked_channels": 6,    // count of channels with engagement data via Insights aggregate OR Analyze per-post (typically all 6 after task #377)
  "posting_channels": 6,                // count of channels we actually post to (full set)
  "channels_engagement_unavailable": [], // only channels with NO engagement data on either surface (rare — empty in steady state)
  "channels_impressions_unavailable": [ // channels using the posts-only ROI fallback (Insights aggregate, no impressions)
    "linkedin/profile (mikelady) — Insights aggregate only; Analyze does not cover LinkedIn personal per-post",
    "threads/profile (mikelady) — Insights aggregate only; Analyze does not cover Threads per-post",
    "threads/profile (enterprisevibecode) — Insights aggregate only; Analyze does not cover Threads per-post"
  ],
  "channels": [
    {
      "id": "...",
      "service": "linkedin",
      "type": "profile",
      "name": "Mike Lady",
      "operational": {
        "queued": 5,
        "sent_in_window": 3,
        "is_paused": false,
        "posting_goal": { "go

…(truncated)
