| name | audience-readout |
| description | Use when someone asks what is working on an X/Twitter account — theirs or a creator they follow — and wants an answer grounded in that account's actual posts rather than general advice. Drives a logged-in browser to collect a real post sample, scores it, finds what separates the winners from the rest, and produces a plain-language read-out with a "so what" on every finding. Also use when asked to calibrate Bangermeter's baseline against a live feed. |
Audience read-out
Turn one account's real post history into a short list of things to keep doing
and things to stop.
Install
This lives in the repo at skills/audience-readout/ so it is browsable, but
Claude Code discovers skills from .claude/skills/ (this project) or
~/.claude/skills/ (everywhere). Copy it to whichever you want:
mkdir -p ~/.claude/skills && cp -r skills/audience-readout ~/.claude/skills/
New-Item -ItemType Directory -Force "$env:USERPROFILE\.claude\skills" | Out-Null
Copy-Item -Recurse -Force "skills\audience-readout" "$env:USERPROFILE\.claude\skills\"
scripts/analyze.js finds Bangermeter's engine by walking up from its own
location, so it keeps working from either place when run inside a clone. Run
from anywhere else and it degrades to rates only, which is fine — it says so in
its header line.
The value is not the scraping. It is refusing to let a pattern become advice
until it survives a confound check, and saying plainly which findings are strong
and which are six posts and a hunch.
Rules that override everything else
1. Never commit an account archive. A collected archive identifies a real
person's posting history. Keep it out of the repo — calibration/*.tsv is
already gitignored for this reason. If a sample must be shared, coarsen it first
(round the denominator, round timestamps) and say so.
2. Read-only. No interactions. Scroll and parse. Never like, follow, repost,
reply, or DM from an automated session. The account belongs to a person.
3. Back off when throttled, and prefer several small sessions to one long
one. Rate limiting is the platform asking you to stop. __arSweep enforces
this rather than trusting you to notice: it stops on the first unclearing
spinner, on a per-session budget (250 posts / 12 minutes by default), and it
jitters every delay so the cadence is not machine-regular. A quiet round escalates the wait rather than quitting — a slow profile can take several seconds to serve the next batch, and giving up early is how a sweep ends at five posts.
A sample gathered over four short sessions is worth exactly as much as one
gathered in a single long sitting, and looks far less like automation. The
collector persists to localStorage specifically so this is easy — see
Collecting across sessions below.
4. Everything is a rate. Raw counts mostly measure how much reach a post
happened to get — which is the thing you are trying to explain. Divide by views,
always.
Procedure
1. Collect
Load scripts/collect.js into the page (paste into the console, or eval it
through browser automation). It defines:
__arCollect() — scrapes every rendered post, dedupes by post ID, skips
anything without a view count, returns { added, total }
__arSweep(n) — scrolls n times, collecting twice per position
__arSave() / __arLoad() — persist to localStorage so a reload or crash
does not lose the sample
__arCoverage() — compares what you have against the post count in the
profile header, and tells you how to describe the shortfall
__arExport() — triggers a TSV download
Collect twice per scroll position. X renders after the scroll settles, so a
single collect call immediately after scrollBy misses most of what appears.
This is the single most common reason a sweep "stalls" at a low count.
When the profile timeline stops serving — usually a few hundred posts, no
spinner, not at the bottom — switch to date-bounded search:
x.com/search?q=from%3AHANDLE%20-filter%3Areplies%20since%3AYYYY-MM-DD%20until%3AYYYY-MM-DD&f=live
The cap is on pagination depth per query, not on history. Walk weekly or
fortnightly windows. Prefer several narrow windows spread across the period over
one wide window — a wide window returns only its most recent slice, which is a
biased sample of itself. Say "stratified across N windows", never "all posts".
1b. Collecting across sessions
The sample lives in localStorage, so a run can be spread over days without
losing anything. This is the recommended shape, not a fallback.
__arLoad();
__arStats();
await __arSweep();
__arExport("sample.tsv");
Between sessions, leave real time — hours, not minutes — and vary what you do.
Different day, different window, different entry point (profile one session,
a date-bounded search the next).
Aim for coverage, not volume. Four sessions of ~60 posts spread across
different weeks beats one session of 240 from a single window, because the
stratification is what makes the sample defensible. Note in the deliverable how
many sessions and windows it came from.
To widen the budget deliberately for one run:
__AR_BUDGET = { posts: 400, minutes: 20 };
Raise it because the sample needs it, not because a sweep ended early.
1b-i. Collection playbook — what actually goes wrong
Learned the hard way across several accounts. Every item here cost a run.
Clear before a new account. localStorage is per-origin, so the previous
account's sample is still sitting there when you open the next profile, and
__arLoad() merges the two into one archive without complaint. The collector now
warns when the restored sample's handle does not match __AR_ONLY, but the fix is
yours:
__arClear();
__AR_ONLY = "handle";
Getting this wrong does not produce an error. It produces a read-out about two
people.
Autosave on a clock before any long run. __arAutosave() flushes every six
seconds. Flushing every N passes is not enough: a tab that navigates — a stray
click, a notification, a link in a post — takes everything since the last flush
with it, and takes the loaded timeline too.
Never navigate during a sweep. Scrolling a long timeline open is expensive,
and it is often a human doing it. Once it is open, treat the tab as read-only:
open anything you want to look at in a different tab.
Use the detached runners for anything long. Browser-automation eval has a
timeout, typically 45 seconds. An await that outlives it kills your call while
the page carries on, so you lose the result and cannot tell whether the sweep is
working. __arRunUp() and __arRunDown() return immediately and publish
progress on window.__run; poll it.
Prefer scrolling up. After a timeline is loaded, going back up re-renders from
cache — fast, no network. Going down fetches. On a very long timeline (200k+
pixels) the renderer bogs down to roughly one pass per 45 seconds going down,
which is slow enough to look broken.
Expect virtualization. Only ten to twenty posts exist in the DOM at once.
Everything scrolled past is discarded, which is why a sweep has to collect as it
goes and cannot parse the page once at the end.
1b-ii. Search windows truncate, and it looks like data
This is the failure most likely to reach a deliverable, because it does not look
like a failure. Search sorts newest-first and stops paginating well before it
exhausts a date window. The older end of the window comes back empty, which reads
as "they did not post then."
The tell: every populated day lands on a window boundary. If windows were cut
on the 1st, 8th, 16th and 24th and the sample only has posts on those dates, the
middles were truncated, not quiet.
Audit every window:
__arWindowAudit("2026-07-15", "2026-07-23");
A LIKELY TRUNCATED verdict means split the window and rerun. On one account a
three-day probe into a supposedly empty stretch returned fifteen posts, seven of
them on a day previously recorded as having none.
Window width depends on posting volume, not on the calendar. A busy account
truncates inside three days; a quiet one survives a week. Start narrow, audit,
widen only if the audit says the spread is complete.
until: is not honored strictly. Windows return posts outside the requested
range. Harmless, since collection dedupes by post ID, but do not use the query
bounds to describe the sample — use the dates actually present in it.
Some gaps are real. Both happen, and only the audit tells them apart: on one
account a June gap was genuinely quiet while an August gap of the same width was
pure truncation. Never report a gap as a quiet period without probing it.
1b-iii. Know your coverage before you write a word
The profile header states a post count — "331 posts". That is ground truth, and
it is the only way to know whether the sample is 90% of the account or 40% of
it. __arCoverage() reads it and does the division for you.
It matters because a timeline can end cleanly and still be nowhere near
complete. Scrolled to the bottom, no spinner, no error, content simply stops —
that reads as "collected everything" and on one account it meant 144 posts of
331. The stop is a pagination cap wearing the costume of an ending.
When the header count and the sample disagree, say so in the deliverable:
"144 of a stated 331 posts (44%)". Then ask which way the missing half biases
the result. If the platform serves the more-visible posts first — which is the
safe assumption — a sample skewed toward the account's better work makes any
"this format underperforms" finding conservative, and any "this format wins"
finding inflated. State which direction applies to the findings you keep.
1b-iv. The tabs render differently, and one of them lies
A profile has three surfaces and they are not interchangeable.
| Tab | What it holds | Reply marked by |
|---|
| Posts | originals and self-threads | "Replying to @x" label |
| with_replies | conversation pairs — their post, then the reply | nothing. the post above it is the context |
| Media | posts carrying an image or video | label, when present |
A reply on /with_replies carries no label. X renders the parent post as its own
article directly above, and that adjacency is the marker. Read only the label there
and every reply files as an original — one pass over that tab returned 74 originals
and 1 reply for an account that barely posts anything but replies.
__arCollect now reads two signals and records which one fired, in replySignal:
label — "Replying to @x" was present
parent — the article directly above belongs to somebody else
first-in-dom — nothing above it to inspect, so the label was all there was
none — neither, so it really is an original
Regression tests for both layouts live in scripts/test-collect.js. Run them after
touching the collector:
node skills/audience-readout/scripts/test-collect.js
Which tab to collect from. Not the obvious one:
- Posts for originals. This is what the read-out analyzes, and the tab is clean.
- with_replies only when replies are the subject. It costs roughly double the
pixels per day, because every reply drags its parent along — on one account a
378,000-pixel scroll bought 26 days, against about 14,600 pixels per day. It reaches
less far back than the Posts tab or search, not further.
- Media is usually empty even on accounts that post images, so treat a blank Media
tab as "did not load", not as "no image posts".
A reply-heavy account is not a broken scrape. An account can be 80–85% replies and
still show a nearly empty Posts tab. That is the account's shape, not a collection
failure, and the answer is to say so in the read-out rather than to keep hunting for
originals that were never there.
1c. Cleaning — what a longer scan drags in
The analyzer cleans by default and says what it dropped. Longer scans need this more,
because they pick up more of what is not comparable.
Post maturity (the one that actually bites). A young post is still accruing views.
Likes arrive fast from followers; views keep coming for days. Measured mid-flight, a fresh
post's rate reads high — about 1.4× on the samples this was calibrated against. Posts
under 48h are dropped by default.
node scripts/analyze.js sample.tsv # cleaned (default)
node scripts/analyze.js sample.tsv --hours=72 # stricter cutoff
node scripts/analyze.js sample.tsv --keep-fresh # keep them, and say so in the write-up
Age is measured against the newest post in the sample, not against the clock, so
analyzing the same file weeks later gives the same answer.
Pinned posts. A pinned post sits at the top of a profile accruing views for months. Its
rate is not comparable to anything. Dropped by default; --keep-pinned overrides.
The 13 Aug 2026 weight change. X published new ranking weights that day. A sample
straddling it mixes two regimes, and the analyzer warns when both sides have ≥5 posts. It
does not drop anything — the split is usually the point of a long scan — but do not report
a before/after trend across that date as if the algorithm held still.
What cleaning will not fix
- Collinear features. If every long post also has an image, "images underperform" and
"long posts underperform" are one finding. Check the overlap before reporting either.
- Reach dilution. Rate falls as reach rises for almost every account. A format used on
high-reach posts will look worse than it is. Compare within view bands.
- Sparse months. The within-month confound check silently needs ≥3 posts per side. On a
bursty account most months fail that, and the check quietly does nothing — say so.
1d. Reading a contrast
Every contrast now carries its own uncertainty. Four things travel with each ratio,
and three of them exist to stop you reporting it.
quote-tweet with 2.02% / 1162 views (n=21) without 3.29% / 1266 views (n=84) ratio 0.61x
90% CI 0.55-0.73x drop top 2: 0.60x
- Under n=10 on either side, no ratio is printed at all. Not "directional", not a
number in italics — nothing. A two-decimal ratio on six posts reads as precision the
data does not have.
- 90% CI — a seeded bootstrap over both groups. Seeded, not random, so re-running the
same file gives the same interval. If it spans 1.0 the line says
INTERVAL SPANS 1.0 — not a finding and you do not report it.
- drop top 2 — the ratio recomputed without the two highest-rate posts on the "with"
side. If it moves more than half, the line says
CARRIED BY OUTLIERS and the finding
was two posts.
- REACH-MATCHED — the same contrast inside view bands.
The interval is about stability, not truth. This is the trap. On the first account
this ran against, long posts scored 0.06x with a 90% CI of 0.03-0.08 — as tight as
this tool ever prints — and it was wrong. Rate falls as reach rises, the long posts were
the high-reach posts, and the ratio was precisely estimating a confound. At matched reach
the same account's short-post advantage collapsed from 2.04x to 0.97x / 0.99x / 1.00x
across three bands: no format effect at all, just reach.
So: a tight interval earns a finding nothing on its own. Read the REACH-MATCHED line
first. If it says feature and reach cannot be separated here, the pooled ratio is not
reportable no matter how good the interval looks.
COLLINEARITY lists feature pairs that are mostly the same posts. Three findings that
are one finding wearing hats is the easiest way to sound three times as confident and be
no more right — on that same account, 80% of image posts were also the long posts.
1e. What the collector cannot see
Worth stating in the write-up rather than discovering later:
- It reads the English UI. Pinned, "Replying to" and repost detection all match
English aria-labels. On another display language those flags silently come back false.
- Stored text is capped at 240 characters.
textLen is the true length, so length
bands are fine, but anything read out of the text itself is not. The question flag is
computed at collection time on the full string for this reason; older archives collected
before that fall back to judging only posts whose text is complete, and the rest are
excluded from the contrast rather than counted as "no".
- Everything comes from the profile timeline — no impressions breakdown, no
follower-vs-non-follower split, no idea what X actually showed anyone.
- There is no API path here. Where X API credits exist,
get_users_posts returns full
text, public_metrics, note_tweet and referenced_tweets — strictly better than
scraping, and it maps onto the same TSV. Write it outside the repo either way.
2. Export and analyze
__arExport() downloads a TSV. Then:
node scripts/analyze.js path/to/sample.tsv
It prints the distribution, the top-vs-bottom quartile profile, length bands,
feature contrasts, and the best and worst posts by rate. If Bangermeter's engine
is present it also scores each post; if not, it falls back to rates alone.
3. Check the confound before believing anything
A contrast is not a finding until you have asked what else could produce it.
The two that matter most here:
- Timing. "Quote-tweets underperform" might just mean he quoted more during
a slow month.
analyze.js re-runs every contrast within each month. If the
effect survives inside each stratum, it is real.
- Reach. Engagement rate falls as reach rises. A "high-performing" format may
simply be one he uses on low-reach posts. Check the median views alongside the
median rate — if they move in opposite directions, reach is doing the work.
4. Write the read-out
Follow references/readout-template.md. Non-negotiables:
- A "so what" under every finding. A number with no action is trivia. If you
cannot write the action, the finding is not ready.
- Lead with the constraint, not the compliment. "Your floor is elite, your
reach is the ceiling" tells someone where to spend effort. "Great engagement!"
does not.
- A confidence section that names the weak findings. Mark anything under
~10 posts as directional only. If someone rebuilds their format around an n=6
result and it does not hold, every other finding loses credibility with it.
- Compare against a real baseline, not a vibe. Bangermeter's
observedRates carries measured medians from a real feed sample — use those
as the "typical post" reference and say where they came from.
Failure modes seen in the field
| Symptom | Cause | Fix |
|---|
| Sweep ends at "post budget" / "time budget" | Working as designed | Save, come back later, __arLoad() |
| Sweep ends at "throttled" | Spinner would not clear | Stop for the day. Do not retry immediately |
| Sweep stalls at 5–15 posts | Page serves slower than the sweep waits | Patience now escalates on a quiet round. If it still stalls, the page is throttled — stop |
| Search returns the same posts repeatedly | Not deduping by post ID | __arCollect dedupes; do not bypass it |
| Profile stops loading, no spinner, not at bottom | Pagination depth cap | Switch to date-bounded search windows |
| Every post scores 100 | Account far above baseline; the score saturates | Rank by rate instead, and say the score saturated |
| A contrast vanishes when you look again | It was a timing artifact | This is the confound check working — report the null |
| Counts look 10× off | Parser split a thousands separator | X aria-labels have no separators; check before "fixing" |
| Search returns "No results" for an account you can see posting | The Latest tab does not index every account — small or new ones are often absent from it entirely | Drop &f=live and use the Top tab. It returns a ranked subset rather than everything, so it supplements windows, it does not exhaust them |
eval() of a stored collector throws a CSP error | x.com forbids unsafe-eval; new Function is blocked too | Paste the collector inline after every navigation. Stashing its source in localStorage to re-run later does not work |
| Timeline ends cleanly but the sample is far short of the header count | Pagination cap that looks like an ending — at the bottom, no spinner, no error | Check coverage against the header count (§1b-iii) and report the shortfall rather than the sample |
Scoring caveat
Bangermeter's E score is calibrated so a typical feed post lands near 50. An
account running several times baseline will pin at 100 on most posts, and the
score stops discriminating. When that happens, rank by like rate and say the
score saturated — do not present a wall of 100s as a finding.