Skip to content

Commit 10bfc8b

Browse files
Merge pull request #119 from conorbronsdon/feat/post-rankings-101
Add rank_posts for bounded post rankings from email statistics (#101)
2 parents d087dec + 8e7ec8c commit 10bfc8b

17 files changed

Lines changed: 1302 additions & 35 deletions

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ and the git tag history (`v0.1.0`–`v0.5.0`).
99
## [Unreleased]
1010

1111
### Added
12+
- `rank_posts` (#101) ranks posts by one metric from Substack's dashboard email statistics: views,
13+
opened, sent, open_rate, click_through_rate, signups, subscribes, estimated_value or post_date, in
14+
either direction. It makes one read of up to 20 rows, Substack's page limit, and reports total and
15+
next_offset. Only metrics whose server-side sorting was checked live are accepted. Rows keep
16+
Substack's order and mark each ranked value as reported, null or absent, and nothing is filled in
17+
or recomputed. Rate denominators are documented as unknown. See
18+
[docs/analytics-rankings.md](docs/analytics-rankings.md).
1219
- Native footnotes in long-form drafts (#104). A GFM reference `[^id]` in a top-level paragraph
1320
becomes a `footnoteAnchor`, and its one-paragraph definition becomes a `footnote` block directly
1421
after that paragraph. Numbers follow reference order, matching a structure captured from the
@@ -23,6 +30,16 @@ and the git tag history (`v0.1.0`–`v0.5.0`).
2330
declared type. Failures return a typed `code` with `upload_attempts: 0`. The Cloudflare Worker returns
2431
`remote_image_unavailable`. See [docs/remote-images.md](docs/remote-images.md).
2532

33+
### Changed
34+
- `get_post_analytics` says why a post was not found (#101): `search_result` is `archive_exhausted`
35+
when the search reached the end of the published feed with consistent pages (separate reads, so
36+
concurrent publishing or deletion can still hide a post), `scan_bound_reached` when the 500-post bound was hit
37+
first, or `feed_incomplete` when the feed's pages were incomplete or inconsistent (fewer posts than
38+
reported, a changing total, or repeated posts), so an unsearched post's statistics are reported as
39+
unknown rather than absent. Not-found
40+
results add `scanned` and `feed_capped` (the feed's `isCapped` flag, uninterpreted), and found
41+
posts add `stats_available`. Existing fields are unchanged.
42+
2643
## [1.1.1] - 2026-09-14
2744

2845
### Fixed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ authoritative wording.
233233
| `get_post_comments` | Get comments on a published post |
234234
| `get_sections` | List your publication's sections (categories) with their IDs |
235235
| `get_post_analytics` | Get a published post's stats (views, opens, signups, subscribes, reactions) by ID |
236+
| `rank_posts` | [Rank posts](docs/analytics-rankings.md) by views, opens, sends, rates, signups, subscribes, estimated value or date, keeping null and missing values distinct |
236237
| `list_scheduled_posts` | List posts scheduled for future publication (read-only; scheduling stays in Substack's editor) |
237238

238239
### Archive search and draft review

docs/analytics-rankings.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Post rankings
2+
3+
`rank_posts` ranks posts by one metric from Substack's dashboard email
4+
statistics. It is a single read and never changes anything.
5+
6+
```json
7+
{ "metric": "subscribes", "direction": "desc", "limit": 10, "offset": 0 }
8+
```
9+
10+
| Input | Values | Default |
11+
| --- | --- | --- |
12+
| `metric` | `views`, `opened`, `sent`, `open_rate`, `click_through_rate`, `signups`, `subscribes`, `estimated_value`, `post_date` | `views` |
13+
| `direction` | `desc`, `asc` | `desc` |
14+
| `limit` | 1–20 | 10 |
15+
| `offset` | 0 or more | 0 |
16+
17+
Only metrics whose sorting was checked against the live endpoint are accepted.
18+
The endpoint silently accepts unknown sort fields and misspelled parameters, so
19+
anything else is rejected before a request is made. No filters are supported:
20+
an extra argument such as `section_id` is rejected rather than ignored, so a
21+
result is never presented as filtered when it is not. Substack rejects page
22+
sizes above 20.
23+
24+
## Result
25+
26+
Each row has its `rank` (position from `offset + 1`), `post_id`, `title`,
27+
`post_date`, `type`, the ranked `value`, a `value_state`, a fixed set of
28+
`metrics`, and `absent_metrics`. The page also reports `total`, `returned`,
29+
`has_more`, `next_offset` and `unreported_in_page`.
30+
31+
| `value_state` | Meaning |
32+
| --- | --- |
33+
| `reported` | Substack returned a number (or date) |
34+
| `null` | Substack returned `null` |
35+
| `absent` | The row omitted the field |
36+
37+
`null` and `absent` are not zero, and this server never fills them in. In
38+
`metrics`, both appear as `null`; `absent_metrics` names the omitted fields.
39+
40+
## Ordering and coverage
41+
42+
Rows keep Substack's order; nothing is re-sorted. Checked on September 14, 2026:
43+
44+
- Numeric values are correctly ordered in both directions for every accepted metric.
45+
- Rows missing a count field come last in descending order and first in ascending order.
46+
- For `open_rate` and `click_through_rate`, rows with a `null` rate are placed
47+
among the numeric rows, not at one end. Treat those positions as unranked.
48+
49+
`total` is Substack's count for its email statistics list. It may exclude posts
50+
without email statistics, such as posts that were never emailed. Each page is a
51+
separate read, so rankings can shift between calls if statistics change.
52+
53+
A page must agree with `total`. The page at `offset == total` is empty, which
54+
ends continuation. An empty or short page before that point, or rows past
55+
`total`, is rejected instead of being reported as the end of the list.
56+
57+
## Metric meanings
58+
59+
Values are passed through as Substack reports them. Substack does not document
60+
the denominators or units of `open_rate` and `click_through_rate`, so this server
61+
does not describe them as percentages of any particular count. `opened` and
62+
`sent` are Substack's own counts; they are not unique-reader counts unless
63+
Substack says so. `estimated_value` is Substack's estimate.
64+
65+
`post_date` is Substack's timestamp string, returned unchanged. It must parse as
66+
a date; its timezone is whatever the string states.
67+
68+
## One post by ID
69+
70+
`get_post_analytics` reads a single post's statistics from the published feed,
71+
searching the 500 most recent published posts. When the post is not found,
72+
`search_result` says why. `archive_exhausted` is only reported when the pages agree: a short final
73+
page with no contradicting total, or full pages that exactly reach a total reported identically on
74+
every page. Every page is still a separate offset read, not an atomic snapshot. If one post is
75+
published and another deleted between reads, the total can stay the same while a post shifts past a
76+
page boundary unseen, so `archive_exhausted` means the search reached the end of the feed as paged,
77+
not proof that the post never existed. Retry when the feed may be changing.
78+
79+
| `search_result` | Meaning |
80+
| --- | --- |
81+
| `archive_exhausted` | The search reached the end of the feed as paged, with consistent pages |
82+
| `scan_bound_reached` | The 500-post bound was reached first. An older post may exist; its statistics are unknown here, not absent |
83+
| `feed_incomplete` | The feed's pages were incomplete or inconsistent: fewer posts than the reported total, a total that changed between pages or appeared on only some pages, more posts than the total, or a post repeated across pages. The search cannot rule the post out; its statistics are unknown here |
84+
85+
`scanned` is the number of posts examined. `feed_capped` passes through the
86+
feed's `isCapped` flag, uninterpreted, or `null` when Substack omits it. A found
87+
post has `stats_available: false` when Substack returned no statistics for it;
88+
its metric fields are then `null`, not zero.
89+
90+
## Errors
91+
92+
HTTP 403 or 404 from the statistics endpoint returns `code:
93+
"analytics_unavailable"` with its `status`. It means Substack did not provide
94+
statistics to this account or publication, not that the ranking is empty. This
95+
server does not check publication tier or eligibility in advance; it reports what
96+
Substack returns.
97+
98+
Rate limiting and other upstream failures return the standard read error with
99+
`status` and a validated `retry_after` when Substack provides one; there is no
100+
automatic retry. A response that does not match the expected shape (including
101+
more rows than requested, duplicate posts or non-numeric metrics) is rejected
102+
without returning partial results.

scripts/test-container.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ assert.equal(status.version, pkg.version);
2424
const catalog = async client => {
2525
assert.equal(client.getServerVersion().version, pkg.version);
2626
const { tools } = await client.listTools();
27-
assert.equal(tools.length, 24);
28-
for (const name of ['get_publication', 'list_drafts', 'plan_draft_update', 'update_draft']) assert.ok(tools.some(tool => tool.name === name));
27+
assert.equal(tools.length, 25);
28+
for (const name of ['get_publication', 'list_drafts', 'plan_draft_update', 'update_draft', 'rank_posts']) assert.ok(tools.some(tool => tool.name === name));
2929
for (const name of ['publish_post', 'delete_post', 'schedule_post']) assert.ok(!tools.some(tool => tool.name === name));
3030
};
3131
const prefix = `substack-smoke-${randomUUID()}`;

scripts/test-package.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const expectedTools = [
2525
'list_scheduled_posts', 'list_subscribers', 'update_draft', 'upload_image',
2626
'search_posts', 'preflight_draft', 'plan_draft_update',
2727
'get_publication',
28-
'list_publication_tags', 'get_post_tags',
28+
'list_publication_tags', 'get_post_tags', 'rank_posts',
2929
].sort();
3030

3131
const requiredFiles = ['package.json', 'server.json', 'README.md', 'LICENSE', 'CHANGELOG.md',
@@ -138,7 +138,7 @@ try {
138138
assert.equal(client.getServerVersion()?.version, pkg.version, 'MCP handshake version must match npm');
139139
const { tools } = await client.listTools({}, { timeout: 10_000 });
140140
assert.deepEqual(tools.map(tool => tool.name).sort(), expectedTools);
141-
assert.equal(tools.filter(tool => tool.outputSchema).length, 20, "Object tools must advertise output schemas");
141+
assert.equal(tools.filter(tool => tool.outputSchema).length, 21, "Object tools must advertise output schemas");
142142
for (const tool of tools) assert.equal(typeof tool.annotations?.readOnlyHint, 'boolean', `Missing annotation: ${tool.name}`);
143143
console.log(`Installed ${pkg.name}@${pkg.version}: ${packed.files.length} files, both bins load, handshake version agrees, all ${tools.length} tools present.`);
144144
} finally {

0 commit comments

Comments
 (0)