diff --git a/CHANGELOG.md b/CHANGELOG.md index 93a0f60..9aa7167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` and `top ` 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 diff --git a/README.md b/README.md index 526f1b0..6afa47e 100644 --- a/README.md +++ b/README.md @@ -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 `, `user ` | -| Reddit | `oc reddit` (via old.reddit.com) | `sub `, `post `, `user `, `search ` | +| Reddit | `oc reddit` (via the Atom feeds on www.reddit.com) | `sub `, `new `, `top `, `post `, `user `, `search ` | | GitHub | `oc gh` | `repo `, `user `, `search `, `trending`, `issues ` | | X | `oc x` | `user `, `post ` | | LinkedIn | `oc linkedin` | `profile `, `company `, `jobs ` (public guest views) | diff --git a/clis/reddit.com.json b/clis/reddit.com.json index 662c2df..9d70849 100644 --- a/clis/reddit.com.json +++ b/clis/reddit.com.json @@ -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"] } } } diff --git a/tests/distill.test.js b/tests/distill.test.js index 7c200f4..8feb350 100644 --- a/tests/distill.test.js +++ b/tests/distill.test.js @@ -145,6 +145,30 @@ test('atom feeds render as pages: entries become headings, bodies unescape', () assert.ok(!text.includes('<'), '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 ", 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')); diff --git a/tests/pages/reddit_post.xml b/tests/pages/reddit_post.xml new file mode 100644 index 0000000..9773b85 --- /dev/null +++ b/tests/pages/reddit_post.xml @@ -0,0 +1 @@ +2026-09-04T12:39:19+00:00/comments/1fixture/.rssWhy does the budget flag round up? : reddit.com/u/fixture_posterhttps://www.reddit.com/user/fixture_poster<!-- SC_OFF --><div class="md"><p>A page that runs a little over the budget prints whole instead of being cut. Is that on purpose?</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href="https://www.reddit.com/user/fixture_poster"> /u/fixture_poster </a> &#32; to &#32; <a href="https://www.reddit.com/r/FixtureSub/"> r/FixtureSub </a> <br/> <span><a href="https://www.reddit.com/r/FixtureSub/comments/1fixture/why_does_the_budget_flag_round_up/">[link]</a></span> &#32; <span><a href="https://www.reddit.com/r/FixtureSub/comments/1fixture/why_does_the_budget_flag_round_up/">[comments]</a></span>t3_1fixture2026-09-01T11:18:08+00:002026-09-01T11:18:08+00:00Why does the budget flag round up?/u/first_replyhttps://www.reddit.com/user/first_reply<!-- SC_OFF --><div class="md"><p>Yes. One extra tool call costs more than the tokens it would save.</p> </div><!-- SC_ON -->t1_c00000012026-09-01T13:50:03+00:00/u/first_reply on Why does the budget flag round up?/u/second_replyhttps://www.reddit.com/user/second_reply<!-- SC_OFF --><div class="md"><p>The README calls it a target rather than a hard cap.</p> </div><!-- SC_ON -->t1_c00000022026-09-01T14:02:11+00:00/u/second_reply on Why does the budget flag round up? diff --git a/tests/sites.test.js b/tests/sites.test.js index c0fad80..65ea1e9 100644 --- a/tests/sites.test.js +++ b/tests/sites.test.js @@ -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 /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 /); });