The Feed
The Superhero home feed at https://superhero.com/ has two modes: a ranked popular feed
and a chronological latest feed. Both are infinite, both blend on-chain posts with token
activity, and the popular ranking is tunable per reader.
Choosing a mode
| Mode | URL | Query |
|---|---|---|
| Popular (default) | /?sortBy=hot | GET /api/posts/popular |
| Latest | /?sortBy=latest | GET /api/posts ordered by created_at descending |
The sort key lives in the URL, so a feed state is linkable. The popular mode is behind the
POPULAR_FEED_ENABLED configuration flag; when it is off the app forces latest and hides the
popular option.
A separate filterBy query parameter filters the assembled list client-side:
| Value | Effect |
|---|---|
all | Everything (default) |
withMedia | Only items carrying media |
withComments | Only items with at least one comment |
What is eligible for popular ranking
The candidate set is posts that are:
- not hidden:
is_hidden = false. The indexer setsis_hiddenwhen any entry in the post's media list contains the substringhidden; - top-level:
post_id IS NULL, so comments are never ranked as standalone items; - within the newest 10,000 posts by
created_at.
Popularity is computed inside that rolling pool, not across every post ever written.
Governance polls also compete, through a plugin interface: a contributor supplies content items
whose ids contain a colon (poll:123), scored with votes_count standing in for comments and
zero for tips and reads. A popular page can therefore return mixed item types.
The scoring formula
Ranking is all-time. Every candidate gets a base score from log-dampened engagement signals:
baseScore =
w_comments * ln(1 + comments)
+ w_tipsAmountAE * ln(1 + tipsAmountAE)
+ w_tipsCount * ln(1 + tipsCount)
+ w_uniqueTippers * ln(1 + uniqueTippers)
+ w_reads * ln(1 + effectiveReads)
+ w_trendingBoost * trendingBoost
+ w_contentQuality * contentQuality
+ w_freshnessBoost * freshnessFactor
+ w_velocityBoost * ln(1 + interactionsPerHour) * freshnessFactor
+ w_interactionsPerHour * ln(1 + interactionsPerHour)
+ tieRotationthen a daily shuffle, then age gravity, then a staleness penalty:
score = (baseScore * dailyShuffle) / (ageHours + 2) ^ 0.25 - stalePenaltyageHours is floored at 1. The + 2 in the divisor stops a brand-new post being divided by
something near zero. The penalty is subtracted after gravity so that an older dead post
does not outrank a younger dead one.
The weights
| Signal | Query parameter | Default weight |
|---|---|---|
| Comments | comments | 2.5 |
| Total tipped AE | tipsAmountAE | 1.5 |
| Number of tips | tipsCount | 1.0 |
| Distinct tippers | uniqueTippers | 1.5 |
| Trending-topic boost | trendingBoost | 0.5 |
| Content quality | contentQuality | 0.3 |
| Reads | reads | 1.5 |
| Engagement velocity | interactionsPerHour | 0.6 |
| Freshness lift | not scalable | 1.5 |
| Freshness-gated velocity amplifier | not scalable | 0.6 |
Eight of the ten are reader-scalable. Each accepts low, med or high, which multiply that
weight by 0.6, 1.0 and 1.5 respectively. med is the default and the client omits it, so a
request carries only the signals you changed. In the app these are the Customize controls
next to the sort selector. Because the multipliers scale the weights, high on every signal
leaves the order unchanged; scaling only matters relative to other signals.
Where each input comes from
| Input | Computed from |
|---|---|
comments | The whole reply thread, counted recursively to a depth cap of 50, excluding replies written by the root post's own author. A hidden comment prunes its entire subtree. |
tipsAmountAE | SUM(amount) over the post's tips, excluding tips from the post's author |
tipsCount | COUNT(*) over the same set |
uniqueTippers | COUNT(DISTINCT sender_address) over the same set |
reads | SUM(reads) from the daily read counts (see below) |
trendingBoost | min(1, highestTrendingTagScore / 100) across the post's topics. The single strongest topic, not the sum. |
contentQuality | A heuristic over the post text (see below) |
interactionsPerHour | interactionsInLast48h / max(1, min(ageHours, 48)), where interactions are comments plus tips |
Velocity counts recent activity over a 48-hour window rather than the post's lifetime, so an old post that catches fire today registers full velocity.
Reads
A read is recorded by GET /api/posts/{id}, the single-post fetch, and is deduplicated and
capped twice:
- At recording time, a viewer is identified by a SHA-256 hash of client IP plus
User-Agent, truncated to 16 hex characters, and a Redis key
reads:seen:<date>:<postId>:<viewer>allows one counted read per viewer, per post, per UTC day. Requests whose User-Agent containsbot,spider,crawler,preview,uptime,monitororcurlare ignored, as is a request with no User-Agent. If Redis is unreachable the read counts. - At scoring time, the counted volume is clamped to
100 × (1 + comments + tipsCount + uniqueTippers)before the logarithm, so passive traffic alone cannot outvote real engagement.
Reads are stored per (post_id, date); there is no per-account read history.
Content quality
A pure function of the post text, in the range 0 to 1, with no author or account input:
len = the post's length in Unicode code points
lengthScore = clamp((len - 10) / 140, 0, 1)
emojiRatio = min(1, emojiCount / max(1, len)) // \p{Extended_Pictographic}
alnumRatio = min(1, alnumCount / max(1, len)) // \p{L} or \p{N}, any script
quality = 0.6 * lengthScore + 0.2 * alnumRatio + 0.2 * (1 - emojiRatio)
if len < 10 and emojiRatio > 0.5: quality = quality * 0.25Length is counted in code points so emoji (surrogate pairs) are not double-counted, and the alphanumeric class covers every script, so a wholly Chinese, Arabic or Cyrillic post is not penalised against a Latin one. At weight 0.3 the term is an anti-spam tiebreaker, not a quality ranking.
Freshness, staleness and the two shuffles
| Term | Rule |
|---|---|
freshnessFactor | 1 for the first 120 hours (5 days), then a linear fade to 0 at 240 hours (10 days) |
stalePenalty | 0 until 240 hours; then ramps linearly over a further 240 hours to a maximum of 5, only if the post has had no comment or tip in the last 48 hours. Any recent interaction zeroes it. Plugin items without interaction timestamps are never penalised. |
tieRotation | A deterministic value in [0, 0.05) from an FNV-1a hash of postId + hourBucket, added before gravity. It reorders near-tied posts every hour without touching genuinely different scores. |
dailyShuffle | A deterministic multiplier in [0.8, 1.2] from a hash of postId + UTC day. Applied only to the default feed. Any weight override switches it off so personalized queries stay deterministic. |
After scoring, each page is reranked for author diversity: candidates are oversampled by 4x (minimum 80), one item per distinct author is taken first, and duplicates backfill only when there are not enough unique authors to fill the page.
The app then applies a second, client-side Fisher-Yates shuffle to each popular page, seeded per session, so the order is stable while you scroll but different on your next visit.
Caching
The default (unpersonalized) ranking lives in the Redis sorted set popular:all, refreshed by
a cron every 10 minutes behind a SET NX lock, with a 1,800-second TTL. A cold key triggers a
lazy recompute while the request in flight falls back to recent posts. Any weight override
bypasses the cache and scores the candidate pool live, so a customized feed is slower than the
default one.
If ranking fails outright, the endpoint returns recent posts with meta.fallback: true and a
meta.error string rather than a 500.
Request shapes
GET /api/posts/popular?page=1&limit=10&tipsCount=high&reads=low
GET /api/posts?order_by=created_at&order_direction=DESC&limit=10&page=1limit on the popular endpoint is 1 to 100, default 50; the web client asks for 10.
debug=1 appends a debug array to the response, one entry per returned item:
{ "id": "3903_v3", "tx": "th_...", "author": "ak_...", "personalized": true,
"appliedWeights": { "comments": 2.5, "reads": 2.25, "...": 0 } }appliedWeights is the resolved weight map after your scale multipliers. It is the quickest way to
check that an override landed.
GET /api/posts accepts more filters than the home feed uses:
| Parameter | Values |
|---|---|
order_by | total_comments or created_at |
order_direction | ASC or DESC |
search | Free-text over content and topics, at most 100 characters |
account_address | Restrict to one author; this is what a profile feed uses |
topics | Comma-separated topic names, partial matching |
limit, page | Pagination |
Both endpoints return the same envelope: an items array and a meta block with
currentPage, totalPages, totalItems and itemCount. Paging is "fetch page N+1 while
currentPage is below totalPages".
Each post item carries id, tx_hash, tx_args, sender_address, an embedded sender
profile, contract_address, type, content, trend_mentions, media, total_comments
and created_at, plus a slug used for SEO links.
What else is in the feed
In latest mode the app runs a second infinite query over GET /api/tokens ordered by
created_at, maps each new token sale and trade into a feed item, and merges the two lists,
so a brand-new community token shows up in the feed without anyone posting about it.
The API's token socket emits two channels, token-updated and token-history; see
WebSocket API. Posts are not pushed over a socket: the feed
refetches on window focus and treats data as fresh for 10 seconds. Your own new post is the
exception: the composer inserts it optimistically before the indexer has seen it; see
Posting.
When popular runs out
The popular feed ranks at most 10,000 candidates. When it is exhausted, or when the first page comes back with fewer than 10 items, the app appends latest posts below, de-duplicated against what popular already returned.
Topic feeds
A hashtag has its own feed. GET /api/topics/name/{name} returns the topic's posts and a
post_count, and token pages render it as the token's discussion tab. That tab defaults to
posts by token holders only, and falls back to everyone when the holders-only filter would
leave it empty. The composer on a topic feed requires the topic's hashtag before the Post
button enables.
See Hashtags & Topics for how a tag becomes a topic.
Related
- Posting: how an item gets into the feed.
- Tipping: three of the eight scalable ranking signals come from tips.
- Comments: the thread count that carries the heaviest weight.
- Hashtags & Topics: topic feeds and where
trendingBoostcomes from. - Posts API: the endpoint-level reference.
- WebSocket API: the channels behind the live activity items.