Merge pull request #53 from only-cli/fix/reddit-atom-feeds

reddit: read the www.reddit.com Atom feeds, old.reddit.com is behind a login now
This commit is contained in:
Mark Cli
2026-09-04 08:45:12 -04:00
committed by GitHub
6 changed files with 60 additions and 5 deletions
+15
View File
@@ -3,6 +3,21 @@
Notable changes per release. Releases before 0.4.0 are listed at
[github.com/only-cli/oc/releases](https://github.com/only-cli/oc/releases).
## Unreleased
### Changed
- `oc reddit` reads the Atom feeds on www.reddit.com instead of old.reddit.com
pages. Reddit has sent every logged-out old.reddit.com request to a login
page since 30 June 2026, and the `.json` views on www.reddit.com have
answered 403 to anything without an OAuth token since 30 May, whatever the
User-Agent or TLS fingerprint. The feeds still answer, so `sub`, `post`,
`user`, and `search` point at them, and `new <name>` and `top <name>` join
the verbs. A subreddit renders in about 480 tokens and a thread with 22
comments in about 1,000. The feeds carry no scores or comment counts, and
anonymous reddit.com allows roughly ten requests a minute per address, so a
burst of shortcuts ends in a 429 that takes minutes to clear. (#52)
## 0.5.1
### Added
+1 -1
View File
@@ -81,7 +81,7 @@ Works on any mostly-static site with no per-site setup: news sites, blogs, docum
| website | command | shortcuts |
| --- | --- | --- |
| Hacker News | `oc hn` | `top`, `new`, `item <id>`, `user <name>` |
| Reddit | `oc reddit` (via old.reddit.com) | `sub <name>`, `post <id>`, `user <name>`, `search <query>` |
| Reddit | `oc reddit` (via the Atom feeds on www.reddit.com) | `sub <name>`, `new <name>`, `top <name>`, `post <id>`, `user <name>`, `search <query>` |
| GitHub | `oc gh` | `repo <owner> <name>`, `user <name>`, `search <query>`, `trending`, `issues <owner> <name>` |
| X | `oc x` | `user <name>`, `post <id>` |
| LinkedIn | `oc linkedin` | `profile <name>`, `company <name>`, `jobs <query>` (public guest views) |
+6 -4
View File
@@ -1,9 +1,11 @@
{
"domain": "reddit.com",
"commands": {
"sub": { "open": "https://old.reddit.com/r/{name}", "args": ["name"] },
"post": { "open": "https://old.reddit.com/comments/{id}", "args": ["id"] },
"user": { "open": "https://old.reddit.com/user/{name}", "args": ["name"] },
"search": { "open": "https://old.reddit.com/search?q={query}", "args": ["query"] }
"sub": { "open": "https://www.reddit.com/r/{name}/.rss", "args": ["name"] },
"new": { "open": "https://www.reddit.com/r/{name}/new/.rss", "args": ["name"] },
"top": { "open": "https://www.reddit.com/r/{name}/top/.rss?t=week", "args": ["name"] },
"post": { "open": "https://www.reddit.com/comments/{id}/.rss", "args": ["id"] },
"user": { "open": "https://www.reddit.com/user/{name}/.rss", "args": ["name"] },
"search": { "open": "https://www.reddit.com/search.rss?q={query}", "args": ["query"] }
}
}
+24
View File
@@ -145,6 +145,30 @@ test('atom feeds render as pages: entries become headings, bodies unescape', ()
assert.ok(!text.includes('&lt;'), 'entry body left escaped');
});
test('a reddit post feed renders as the post followed by its comments', () => {
// Reddit closed old.reddit.com and its .json views to logged-out readers in
// 2026; the Atom feeds on www.reddit.com are what oc reddit rides now. A
// post feed is one entry for the post and one per comment, each comment
// titled "/u/name on <post title>", so the whole thread reads as one page.
const xml = readFileSync(new URL('./pages/reddit_post.xml', import.meta.url), 'utf8');
const p = distill(xml, 'https://www.reddit.com/comments/1fixture/.rss');
assert.equal(p.title, 'Why does the budget flag round up? : reddit.com');
const headings = p.blocks.filter((b) => b.type === 'heading').map((b) => b.text);
assert.deepEqual(headings, [
'Why does the budget flag round up?',
'/u/first_reply on Why does the budget flag round up?',
'/u/second_reply on Why does the budget flag round up?',
]);
const text = p.blocks.map((b) => b.text).join(' ');
assert.ok(text.includes('Is that on purpose?'), 'post body missing');
assert.ok(text.includes('by /u/fixture_poster, 2026-09-01'), 'post byline missing');
assert.ok(text.includes('One extra tool call costs more'), 'comment body missing');
assert.ok(!text.includes('SC_OFF'), 'reddit markup comments leaked into the text');
const rendered = render(p).text;
assert.ok(rendered.includes('## [1] Why does the budget flag round up?'), 'post is not the first numbered heading');
assert.ok(estimateTokens(rendered) < 500, `three-entry thread should fit the default budget, got ${estimateTokens(rendered)}`);
});
test('feed entry code blocks survive raw markdown', () => {
const md = toMarkdown(feed);
assert.ok(md.startsWith('# Why is the sky blue? - Fixture Overflow'));
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/"><category term=" reddit.com" label="r/ reddit.com"/><updated>2026-09-04T12:39:19+00:00</updated><id>/comments/1fixture/.rss</id><link rel="self" href="https://www.reddit.com/comments/1fixture/.rss" type="application/atom+xml" /><link rel="alternate" href="https://www.reddit.com/comments/1fixture/" type="text/html" /><title>Why does the budget flag round up? : reddit.com</title><entry><author><name>/u/fixture_poster</name><uri>https://www.reddit.com/user/fixture_poster</uri></author><category term="FixtureSub" label="r/FixtureSub"/><content type="html">&lt;!-- SC_OFF --&gt;&lt;div class=&quot;md&quot;&gt;&lt;p&gt;A page that runs a little over the budget prints whole instead of being cut. Is that on purpose?&lt;/p&gt; &lt;/div&gt;&lt;!-- SC_ON --&gt; &amp;#32; submitted by &amp;#32; &lt;a href=&quot;https://www.reddit.com/user/fixture_poster&quot;&gt; /u/fixture_poster &lt;/a&gt; &amp;#32; to &amp;#32; &lt;a href=&quot;https://www.reddit.com/r/FixtureSub/&quot;&gt; r/FixtureSub &lt;/a&gt; &lt;br/&gt; &lt;span&gt;&lt;a href=&quot;https://www.reddit.com/r/FixtureSub/comments/1fixture/why_does_the_budget_flag_round_up/&quot;&gt;[link]&lt;/a&gt;&lt;/span&gt; &amp;#32; &lt;span&gt;&lt;a href=&quot;https://www.reddit.com/r/FixtureSub/comments/1fixture/why_does_the_budget_flag_round_up/&quot;&gt;[comments]&lt;/a&gt;&lt;/span&gt;</content><id>t3_1fixture</id><link href="https://www.reddit.com/r/FixtureSub/comments/1fixture/why_does_the_budget_flag_round_up/" /><updated>2026-09-01T11:18:08+00:00</updated><published>2026-09-01T11:18:08+00:00</published><title>Why does the budget flag round up?</title></entry><entry><author><name>/u/first_reply</name><uri>https://www.reddit.com/user/first_reply</uri></author><category term="FixtureSub" label="r/FixtureSub" /><content type="html">&lt;!-- SC_OFF --&gt;&lt;div class=&quot;md&quot;&gt;&lt;p&gt;Yes. One extra tool call costs more than the tokens it would save.&lt;/p&gt; &lt;/div&gt;&lt;!-- SC_ON --&gt;</content><id>t1_c0000001</id><link href="https://www.reddit.com/r/FixtureSub/comments/1fixture/why_does_the_budget_flag_round_up/c0000001/"/><updated>2026-09-01T13:50:03+00:00</updated><title>/u/first_reply on Why does the budget flag round up?</title></entry><entry><author><name>/u/second_reply</name><uri>https://www.reddit.com/user/second_reply</uri></author><category term="FixtureSub" label="r/FixtureSub" /><content type="html">&lt;!-- SC_OFF --&gt;&lt;div class=&quot;md&quot;&gt;&lt;p&gt;The README calls it a target rather than a hard cap.&lt;/p&gt; &lt;/div&gt;&lt;!-- SC_ON --&gt;</content><id>t1_c0000002</id><link href="https://www.reddit.com/r/FixtureSub/comments/1fixture/why_does_the_budget_flag_round_up/c0000002/"/><updated>2026-09-01T14:02:11+00:00</updated><title>/u/second_reply on Why does the budget flag round up?</title></entry></feed>
+13
View File
@@ -42,6 +42,19 @@ test('a real site with a missing or unknown verb names the verbs it has', () =>
/not a reddit\.com shortcut.*sub <name>/s);
});
test('reddit verbs reach the www.reddit.com atom feeds, not old.reddit.com', () => {
// old.reddit.com sends every logged-out request to a login page and the
// .json views on www answer 403, so the feeds are the only public reading.
assert.equal(resolveSite('reddit', ['sub', 'ClaudeAI']).url, 'https://www.reddit.com/r/ClaudeAI/.rss');
assert.equal(resolveSite('reddit', ['post', '1w48zcr']).url, 'https://www.reddit.com/comments/1w48zcr/.rss');
assert.equal(resolveSite('reddit', ['search', 'claude code']).url, 'https://www.reddit.com/search.rss?q=claude%20code');
for (const verb of Object.keys(sites().get('reddit').commands)) {
const { url } = resolveSite('reddit', [verb, 'x']);
assert.ok(url.startsWith('https://www.reddit.com/'), `${verb} left www: ${url}`);
assert.ok(/\.rss(\?|$)/.test(url), `${verb} is not a feed: ${url}`);
}
});
test('a shortcut called with too few args says what it needs', () => {
assert.throws(() => resolveSite('gh', ['repo', 'only-cli']), /usage: oc gh repo <owner> <name>/);
});