Add rank_posts for bounded post rankings from email statistics (#101) - #119
Merged
Merged
Conversation
One read of Substack's dashboard email statistics, ranked by a metric whose server-side sorting was verified live (views, opened, sent, open_rate, click_through_rate, signups, subscribes, estimated_value, post_date) in either direction. Pages are at most 20 rows, Substack's limit, with total and next_offset; an empty page ends continuation. Rows keep Substack's order and mark the ranked value as reported, null or absent. Nothing is re-sorted, filled in or recomputed, and rate denominators are documented as unknown. Unverified sort fields, malformed rows, duplicates and oversized pages are rejected before any result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcyA1s6dUxtZfc2bMV7fuf
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcyA1s6dUxtZfc2bMV7fuf
…ecks (review) - get_post_analytics says why a post was not found: archive_exhausted when the whole feed was searched, scan_bound_reached when the 500-post bound was hit first. Not-found results add scanned and feed_capped (isCapped, uninterpreted); found posts add stats_available. getPostAnalytics keeps its signature via the new findPostAnalytics. - rank_posts rejects unknown arguments instead of silently dropping them, rejects pages that contradict total, requires a parseable post_date, and reports HTTP 403/404 from statistics as analytics_unavailable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcyA1s6dUxtZfc2bMV7fuf
The container job asserted exactly 24 tools and failed on every commit of this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcyA1s6dUxtZfc2bMV7fuf
…review) A short page no longer claims the archive was searched when total says more posts remain; that case is feed_incomplete. Ten full pages that reach the reported total are archive_exhausted rather than scan_bound_reached. Without a reported total, a short page still ends the feed and full pages cannot prove exhaustion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcyA1s6dUxtZfc2bMV7fuf
findPostAnalytics now reports feed_incomplete when the paginated reads do not agree: a total that changes between pages or appears on only some of them, more posts than the total, or a post repeated across pages. Full pages count as exhausted only when they exactly reach a total reported identically on every page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcyA1s6dUxtZfc2bMV7fuf
Offset pages are separate reads. A post published and another deleted between reads can shift a post past a page boundary with an unchanged total and no duplicate IDs, which no pagination check can detect. The not-found note, tool description, docs and changelog now say the search reached the end of the feed as paged and that concurrent changes can hide a post, instead of claiming every post was searched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcyA1s6dUxtZfc2bMV7fuf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #101.
What changed
rank_postsis read-only. It makes one GET to/api/v1/publication/stats/email_statsand ranks posts by one metric,descorasc.views,opened,sent,open_rate,click_through_rate,signups,subscribes,estimated_valueorpost_date. The endpoint silently accepts unknown sort fields, so only fields whose sorting was verified live are allowed.limitis 1–20, which is Substack's cap, plus the usualoffset. Results reporttotal,returned,has_moreandnext_offset. An empty page ends continuation.rank,post_id,title,post_date,type, the rankedvalueand avalue_state(reported,nullorabsent). It also carries a fixedmetricsset and anabsent_metricslist.nullandabsentare never zero.totalmay exclude posts that have no email statistics.get_post_analytics.docs/analytics-rankings.md, plus README and CHANGELOG updates.Evidence
Read-only probes from 2026-09-14, already posted on #101:
limitabove 20 returns 400, and the page after the last one is empty.nullvalues are distinct.A live smoke test of this code (GET-only, printing only derived facts) checked
views desc,open_rate desc,subscribes ascandpost_date desc. Each returned 20 rows with contiguous ranks and sorted reported values. Theopen_ratepage had 13 reported, 6 null and 1 absent. A request past the end returned an empty page withhas_more: false.Checks (at 8e7ec8c)
test:packagepass. The package test now expects 25 tools, 21 with output schemas.rank_posts, plus the new optionalget_post_analyticsfields.retry_afterwithout retryinghas_moreruleReview
Round 1 on 25d3248 covered Codex, OpenCode MiMo v2.5 Pro and Hermes Inkling.
### Addedchangelog heading. Fixed in 9b04d51.Eligibility validation before any statistics read. Not treated as blocking: the maintainer chose to ship rankings without waiting on Publication eligibility, workflow validation and analytics priorities #66. The concrete part is fixed. HTTP 403/404 from statistics now returns
code: "analytics_unavailable"instead of a generic upstream error, and the docs say the server does not pre-check tier or eligibility.get_post_analyticsscan-bound ambiguity, Add bounded post analytics rankings with explicit coverage and metric semantics #101's original problem. Fixed. Not-found results now report:search_result: "archive_exhausted"when the whole feed was searched, or"scan_bound_reached"when the 500-post bound was hit firstscannedfeed_capped, the feed'sisCappedflag passed through uninterpretedFound posts report
stats_available.getPostAnalyticskeeps its signature.Unknown arguments silently stripped. Fixed. The input schema is strict, so
section_idis rejected before any request.Contradictory pages reported as exhausted. Fixed. An empty or short page before
total, or rows pasttotal, is rejected. The earlier test that accepted this was replaced.post_dateunvalidated (MINOR). Fixed. It must parse as a date and is documented as Substack's string, unchanged.Each of these fixes fails a test when disabled: strict input (library and MCP schema), mid-list pages, rows past total, date parsing,
analytics_unavailable, exhausted versus bound, theisCappedpassthrough andstats_available.Round 2 on 1b556c8:
Hermes Inkling: nothing blocking. All round-1 fixes were confirmed.
Codex: confirmed the strict input, page consistency,
post_dateandanalytics_unavailablefixes, and found one BLOCKER.findPostAnalyticsignored the feed'stotal: a short page claimed every post was searched even whentotalsaid more remained, and a feed of exactly 500 posts reported the scan bound. Fixed in bd76149:feed_incompletearchive_exhaustedDisabling any of these three checks makes a test fail.
CI: the container smoke test expected exactly 24 tools and failed on every commit of this branch. Fixed in adf1d22.
Schema regression caught before push: an inline comment in
output-contracts.tshad dropped fiveget_post_analyticsoutput-schema fields. The contract snapshot caught it, and the final snapshot diff is additions only.Round 3 on bd76149:
Codex: two BLOCKERs, both about claiming every post was searched when the feed's pages disagreed.
totalwas kept, so a total that changed mid-scan could still producearchive_exhausted.total: 0with ten full pages) counted as proof of exhaustion.Fixed in 5db4587. Exhaustion is now claimed only from consistent pages. These cases all return
feed_incomplete:Full pages count as exhausted only when they exactly reach a total reported identically on every page.
Mutation checks: each of the three new guards fails a test when disabled.
Live check: a read-only run against the publication still reports
archive_exhausted. Its feed reports a total of 19 and returns 19 posts.Round 4 on 5db4587:
totalon every page, a short final page still ends the feed, as before this PR.archive_exhaustednow means the search reached the end of the feed as paged, not proof that the post never existed. The not-found note, tool description, docs and changelog say pages are separate reads, and that concurrent publishing or deletion can hide a post. This follows the existinglocal_snapshotand export wording for multi-read tools.Not in scope
🤖 Generated with Claude Code
https://claude.ai/code/session_01AcyA1s6dUxtZfc2bMV7fuf