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:
- PATH: the browse binary spawns
bun, which isn't on a cron shell's PATH —export PATH="$HOME/.bun/bin:$PATH"before any$Bcall (symptom:Executable not found in $PATH: "bun"fromstartServer).- 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
operationalfast 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:
- Channel set is now 4, not 6. LinkedIn personal (
mikelady) and Threads personal (mikelady) are DISCONNECTED from Buffer (orglimits.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.- The Buffer MCP token lacks
insights:readscope —list_posts/get_postwithincludeMetrics: truereturns 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 withinsights:read.- 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 the7 daysbutton 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 byconfig.organization_name(or the only one). SeeEdge: multi-org-selection.mcp__buffer__list_channels→ filter outisDisconnected: true, applychannels_include/channels_exclude.- Per channel:
mcp__buffer__get_channelfor posting goal, schedule, paused flag.mcp__buffer__list_poststwice —status: [scheduled]for queue depth,status: [sent]withcreatedAt.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, emitengagement_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) / postsfrom the Insights row, bucket via the posts-only rubric in Phase 2c, emitengagement_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):
~/dev/claude-social-media-skills/buffer-stats/config.local.json(gitignored — personal org/channel overrides)~/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/flywheelconsumes 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:
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.
Resolve organization
- Call
mcp__buffer__get_account→ readorganizations[]. - If
config.organization_nameis 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 toconfig.local.json.
- Call
List channels
- Call
mcp__buffer__list_channelswithorganizationId. - Filter out
isDisconnected: true. - Apply
channels_include/channels_excludefilters from config.
- Call
Per-channel operational metrics
- For each channel, call
mcp__buffer__get_channelto grab:postingGoal—{ goal, sentCount, scheduledCount, status, periodStart, periodEnd }(may benullif no goal set)postingSchedule— weekly cadence arrayisQueuePausedservice+type(e.g.linkedin/profile,instagram/business)
- For each channel, call
mcp__buffer__list_postswith:filter.channelIds: [channelId],filter.status: [scheduled],first: 100→ queue depthfilter.channelIds: [channelId],filter.status: [sent],filter.createdAt: { start: <N days ago> },first: 100→ sent count in window, plus tag distribution
- For each channel, call
Organization-level flags
- Paused queues, posting goals
AtRisk, channels with empty queues, channels over weekly limit.
- Paused queues, posting goals
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):
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."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)
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:
$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).
$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
rowRegexabove returns[]. Buffer moved the breakdown into a<table>(headerChannel | Posts | Reactions | Comments | Eng. Rate) lower on the page, between thePerformanceheading and theMetricsheading. Each cell carriesvalue\nUp/Down\npct%. Use the table extractor below; keep therowRegexas a fallback in case Buffer reverts.$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
namefield, so all three EVC channels readenterprisevibecodeand both personal channels readmikelady. Resolve by: (a)facebook/pageis the onlyEnterprise Vibe Code(capitalized); (b) cross-reference the 3 Analyze channelIds (FB/LI-page/IG appear atanalyze.buffer.comhome asa[href*=/overview/]) against post counts to pin LI-page vs IG vs Threads-EVC; (c) for the twomikeladyrows, the one with comments > 0 islinkedin/profile(the engagement leader), the comment-less one isthreads/profile. Confirm IG by matching its Analyze "Posts" count to the table row.⚠️ The Insights table UNDERCOUNTS Facebook reactions (reports FB
Reactionsas0). 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 Insights0— 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
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:
$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.
# 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:
# 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:
Impressionsshows a "not available" banner on some plans — parse asnull. - 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):
$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 byImpressionsdesc, take top N fromconfig.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:
"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'sbufferMatchresolves IG/FB/LinkedIn-page opus-clip derivatives by reading a top-levelrecent_posts[]in this snapshot — the SAME shape tiktok-stats/threads-stats write, plus achannelfield. Each entry:{ "channel": "<service>/<type>", "caption": "...[opus:<id>]", "source_tag": {"scheme":"opus","id":"<id>"}, "engagement": {"impressions":N,"reach":N,...} }. Thechannelfield (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 arecent_posts[]entry with that channel + the matchedsource_tag.id. Constraint: Buffer Analyze's posts tab paginates/virtualizes —window.scrollTodoes 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-postscheduleId+ a future per-post analytics endpoint). Until then, emit what the first page yields + note partial coverage. Old snapshots withoutrecent_posts[]staypending #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[].
{
"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)