Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,27 @@
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

### Fixed

- A feed entry's title is now the link to the entry, so `oc do <n>` on a post
in a subreddit feed opens it. The link used to sit beside the byline as an
anchor labelled `open`, the same label on every entry, and the
repeated-controls filter hid them all on any feed with five or more entries,
which left nothing in a listing that led anywhere: an agent asked to open
the first post's comments got the heading text back, refetched the feed
looking for the link, and met Reddit's 429 (#59).

### Changed

- `oc open` on a reddit.com front page, subreddit, post, user or search URL
fetches the matching www.reddit.com Atom feed when no login session is
held, since the HTML page ends at a login wall for a logged-out reader.
Following a post out of a feed lands on its comments feed instead of the
wall. A URL that is already a feed, any other reddit.com path, and any
request carrying reddit.com cookies are fetched as asked (#59).

## 0.5.3

### Changed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Works on any mostly-static site with no per-site setup: news sites, blogs, docum

A shortcut only ever resolves to a URL and then takes the same path `oc open` does, so it changes nothing about what a page costs or how it reads. The last argument takes every word after it, so `oc ddg search claude code cli` and `oc aws search s3 lifecycle rules` need no quoting, and a path argument keeps its slashes, so `oc learn doc azure/aks/what-is-aks` reaches that page.

A few of these (X, Reddit, Stack Overflow, YouTube, Microsoft Learn search) read pages that look login-gated or JS-only from the outside, by finding the server-rendered HTML, feed, inline data, or public API the page already ships without a login. Stack Overflow search goes through the Stack Exchange API, and each result prints its `question_id`: read one with the `question <id>` feed rather than following its link, since the question page itself answers a bot challenge instead of the question. Reddit goes through the Atom feeds on www.reddit.com: old.reddit.com has sent logged-out readers to a login page since June 2026 and the `.json` views answer 403 without an OAuth token, so a reddit.com page URL handed to `oc open` still meets that wall, where `oc reddit post <id>`, or the same URL with `/.rss` on the end, reads the post and its comments. oc asks reddit.com with its Firefox fingerprint first, because Reddit's edge refuses the Chrome one more often than not. The feeds carry titles, authors, dates, and bodies but no scores or comment counts, and anonymous reddit.com meters each client tightly: a second request within half a minute of the first has come back 429 in testing, and a burst of Reddit shortcuts ends in refusals that take minutes to clear, so space them out. AWS, Google Cloud, Rust, Java, TypeScript, PHP, and cppreference render docs search client-side, or as a page too bare for oc to read, so their `search` goes through DuckDuckGo with a baked-in `site:` filter instead; Go needs no such fallback, because pkg.go.dev renders its search results on the server and `oc go search` simply opens them. Python's docs are built with Sphinx, which publishes the site's full-text search index as one static file, so `oc py search` fetches that index (cached on disk for a day), ranks it locally, and prints a numbered result list; a query that names a symbol exactly, like `json.dumps`, links straight to its anchor. The same backend will work for any Sphinx site, including most Read the Docs projects. MDN also renders its search client-side, but the page gets its results from a public JSON endpoint, so `oc mdn search` asks that endpoint directly and prints the site's own ranking; that `api` shape in a site definition works for any site whose search answers as JSON. Node.js ships no search endpoint at all, but publishes its whole API reference as one static JSON file, so `oc node search` ranks that file locally the same way the Sphinx backend does, under the same day cache, and every module, class, method, property, and event heading links to its own anchor. Ruby's docs are built with RDoc, which also ships its search index as one static file, so `oc ruby search` ranks every class, method, and guide page locally the same way. PHP's manual has a lookup endpoint that sends an exact function name straight to its page, which is what `oc php fn` rides. Not supported yet: pages that only render with JavaScript and sites with hard bot challenges that expose no feed. Sites that genuinely require your account can be reached with `oc login` (bring your own cookies).
A few of these (X, Reddit, Stack Overflow, YouTube, Microsoft Learn search) read pages that look login-gated or JS-only from the outside, by finding the server-rendered HTML, feed, inline data, or public API the page already ships without a login. Stack Overflow search goes through the Stack Exchange API, and each result prints its `question_id`: read one with the `question <id>` feed rather than following its link, since the question page itself answers a bot challenge instead of the question. Reddit goes through the Atom feeds on www.reddit.com: old.reddit.com has sent logged-out readers to a login page since June 2026 and the `.json` views answer 403 without an OAuth token, so `oc open` on a reddit.com front page, subreddit, post, user or search URL reads the matching feed instead when no login session is held, which is also how `oc do <n>` on a post in a subreddit feed reaches its comments; `oc reddit post <id>` is the same feed by hand. oc asks reddit.com with its Firefox fingerprint first, because Reddit's edge refuses the Chrome one more often than not. The feeds carry titles, authors, dates, and bodies but no scores or comment counts, and anonymous reddit.com meters each client tightly: a second request within half a minute of the first has come back 429 in testing, and a burst of Reddit shortcuts ends in refusals that take minutes to clear, so space them out. AWS, Google Cloud, Rust, Java, TypeScript, PHP, and cppreference render docs search client-side, or as a page too bare for oc to read, so their `search` goes through DuckDuckGo with a baked-in `site:` filter instead; Go needs no such fallback, because pkg.go.dev renders its search results on the server and `oc go search` simply opens them. Python's docs are built with Sphinx, which publishes the site's full-text search index as one static file, so `oc py search` fetches that index (cached on disk for a day), ranks it locally, and prints a numbered result list; a query that names a symbol exactly, like `json.dumps`, links straight to its anchor. The same backend will work for any Sphinx site, including most Read the Docs projects. MDN also renders its search client-side, but the page gets its results from a public JSON endpoint, so `oc mdn search` asks that endpoint directly and prints the site's own ranking; that `api` shape in a site definition works for any site whose search answers as JSON. Node.js ships no search endpoint at all, but publishes its whole API reference as one static JSON file, so `oc node search` ranks that file locally the same way the Sphinx backend does, under the same day cache, and every module, class, method, property, and event heading links to its own anchor. Ruby's docs are built with RDoc, which also ships its search index as one static file, so `oc ruby search` ranks every class, method, and guide page locally the same way. PHP's manual has a lookup endpoint that sends an exact function name straight to its page, which is what `oc php fn` rides. Not supported yet: pages that only render with JavaScript and sites with hard bot challenges that expose no feed. Sites that genuinely require your account can be reached with `oc login` (bring your own cookies).

Want a website on that list? Open a pull request, or an issue naming the site; see [CONTRIBUTING.md](CONTRIBUTING.md).

Expand Down
12 changes: 9 additions & 3 deletions src/distill.js
Original file line number Diff line number Diff line change
Expand Up @@ -504,9 +504,15 @@ export function feedToHTML(text) {
// HTML itself, ready to be embedded and parsed like any page.
const body = (entry.querySelector('content') ?? entry.querySelector('summary') ?? entry.querySelector('description'))?.textContent ?? '';
parts.push('<article>');
if (title) parts.push(`<h2>${esc(title)}</h2>`);
if (byline || href) {
parts.push(`<p>${esc(byline)}${href ? ` <a href="${esc(href)}">open</a>` : ''}</p>`);
// The title is the link. It used to sit beside the byline as an anchor
// labelled "open", the same label on every entry, and the repeated-controls
// filter hid the lot as chrome, so nothing in a subreddit feed led to a
// post: `do <n>` on one read its heading instead of following it (#59). A
// heading that is exactly one anchor becomes a followable heading in the
// walk, so the number the agent already sees is the one that opens it.
if (title) parts.push(`<h2>${href ? `<a href="${esc(href)}">${esc(title)}</a>` : esc(title)}</h2>`);
if (byline || (href && !title)) {
parts.push(`<p>${esc(byline)}${href && !title ? ` <a href="${esc(href)}">open</a>` : ''}</p>`);
}
parts.push(body, '</article>');
}
Expand Down
38 changes: 37 additions & 1 deletion src/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,10 @@ export function proxyGet(url, proxy, headers = {}, tlsOpts = {}) {
* identity got the page (impers:chrome, impers:firefox, or fetch)
*/
export async function fetchPage(url, { jar } = {}) {
const target = /^https?:\/\//i.test(url) ? url : `https://${url}`;
let target = /^https?:\/\//i.test(url) ? url : `https://${url}`;
// A reader with no cookies for reddit.com gets the feed where the page
// would be a login wall; one who logged in gets the page it asked for.
if (!jar?.cookieHeaderFor(target)) target = redditFeedURL(target) ?? target;
await assertSafeTarget(target);
const impers = await loadImpers();
return impers ? viaImpers(impers, target, jar) : viaFetch(target, jar);
Expand Down Expand Up @@ -562,6 +565,39 @@ function captureSetCookie(jar, url, res) {
// entry.
const FIREFOX_FIRST_HOSTS = ['reddit.com'];

// Reddit has sent logged-out readers of its HTML pages to a login page since
// June 2026 (#52), while the Atom feed beside each of those pages still
// answers. A feed entry links to the page, so following a post out of a
// subreddit feed used to land on the wall. The front page, subreddit
// listings, posts, user pages and search are mapped to their feeds here;
// anything else on reddit.com is fetched as asked.
const REDDIT_HOSTS = ['reddit.com', 'www.reddit.com', 'old.reddit.com', 'new.reddit.com', 'np.reddit.com'];
const SEG = '[A-Za-z0-9_.-]+';
const REDDIT_FEED_PATHS = new RegExp(
`^(?:|/r/${SEG}(?:/(?:new|top|hot|rising))?|/(?:r/${SEG}/)?comments/${SEG}(?:/${SEG}){0,2}|/u(?:ser)?/${SEG})$`,
);

/**
* The www.reddit.com Atom feed for a reddit.com page URL, or null when the URL
* is not one of the page shapes that has a feed, or is a feed already.
* @param {string} target
* @returns {string | null}
*/
export function redditFeedURL(target) {
let u;
try {
u = new URL(target);
} catch {
return null;
}
if (!REDDIT_HOSTS.includes(u.hostname.toLowerCase())) return null;
const path = u.pathname.replace(/\/+$/, '');
if (/\.(?:rss|json|xml)$/i.test(path)) return null;
if (path === '/search') return `https://www.reddit.com/search.rss${u.search}`;
if (!REDDIT_FEED_PATHS.test(path)) return null;
return `https://www.reddit.com${path.replace(/^\/u\//, '/user/')}/.rss${u.search}`;
}

/**
* The order in which impers identities are tried for a URL.
* @param {string} target
Expand Down
27 changes: 25 additions & 2 deletions tests/distill.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,10 @@ test('atom feeds render as pages: entries become headings, bodies unescape', ()
const headings = p.blocks.filter((b) => b.type === 'heading');
assert.equal(headings[0].text, 'Why is the sky blue?');
assert.equal(headings[1].text, 'Answer by Tyndall for Why is the sky blue?');
const open = p.blocks.find((b) => b.type === 'link' && b.text === 'open');
assert.equal(open.href, 'https://example.test/questions/42/why-is-the-sky-blue');
// The title is the entry's link, so the number the agent sees is the one
// that follows it. It used to be a separate anchor labelled "open".
assert.equal(headings[0].href, 'https://example.test/questions/42/why-is-the-sky-blue');
assert.equal(p.blocks.find((b) => b.type === 'link' && b.text === 'open'), undefined, 'the open anchor is back');
const text = p.blocks.map((b) => b.text).join(' ');
assert.ok(text.includes('Rayleigh scattering'), 'entry body missing');
assert.ok(text.includes('by Ray Leigh, 2026-04-08'), 'byline missing');
Expand All @@ -159,6 +161,9 @@ test('a reddit post feed renders as the post followed by its comments', () => {
'/u/first_reply on Why does the budget flag round up?',
'/u/second_reply on Why does the budget flag round up?',
]);
const post = p.blocks.find((b) => b.type === 'heading');
assert.equal(post.href, 'https://www.reddit.com/r/FixtureSub/comments/1fixture/why_does_the_budget_flag_round_up/',
'the post heading is not the link to the post');
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');
Expand All @@ -169,6 +174,24 @@ test('a reddit post feed renders as the post followed by its comments', () => {
assert.ok(estimateTokens(rendered) < 500, `three-entry thread should fit the default budget, got ${estimateTokens(rendered)}`);
});

test('every entry in a long feed keeps its link', () => {
// A subreddit feed carries 25 entries. Their links used to share the label
// "open", and five of one label is what the repeated-controls filter drops,
// so a whole listing rendered with nothing that led to a post.
const entries = Array.from({ length: 25 }, (_, i) =>
`<entry><title>Post ${i}</title><link href="https://www.reddit.com/r/Fixture/comments/p${i}/post_${i}/"/>
<author><name>/u/poster</name></author><updated>2026-09-04T00:00:00+00:00</updated>
<content type="html">&lt;p&gt;Body ${i}&lt;/p&gt;</content></entry>`).join('');
const xml = `<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Fixture</title>${entries}</feed>`;
const p = distill(xml, 'https://www.reddit.com/r/Fixture/.rss');
const headings = p.blocks.filter((b) => b.type === 'heading');
assert.equal(headings.length, 25);
assert.ok(headings.every((h, i) => h.href === `https://www.reddit.com/r/Fixture/comments/p${i}/post_${i}/`),
'a heading lost its link');
assert.ok(!p.blocks.some((b) => b.type === 'divider' && /repeated controls/.test(b.text)),
'the entry links were hidden as repeated controls');
});

test('feed entry code blocks survive raw markdown', () => {
const md = toMarkdown(feed);
assert.ok(md.startsWith('# Why is the sky blue? - Fixture Overflow'));
Expand Down
28 changes: 27 additions & 1 deletion tests/fetch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import https from 'node:https';
import net from 'node:net';
import tls from 'node:tls';

const { fetchPage, followRedirects, identityOrder, resolveProxy, proxyGet, viaImpers } = await import('../src/fetch.js');
const { fetchPage, followRedirects, identityOrder, redditFeedURL, resolveProxy, proxyGet, viaImpers } = await import('../src/fetch.js');

const BLOCKED_MESSAGE = 'blocked: private or internal URL';

Expand Down Expand Up @@ -810,6 +810,32 @@ test('reddit.com is asked with the firefox fingerprint first', () => withoutProx
assert.equal(page.status, 200);
}));

test('reddit.com page URLs are read as their atom feeds, feeds and everything else as asked', () => {
// The HTML pages end at a login wall for a logged-out reader, so the page
// shapes that have a feed beside them are fetched as that feed: this is what
// lets `oc do <n>` on a post in a subreddit feed reach its comments (#59).
assert.equal(redditFeedURL('https://www.reddit.com/r/ClaudeAI/comments/1w48zcr/some_title/'),
'https://www.reddit.com/r/ClaudeAI/comments/1w48zcr/some_title/.rss');
assert.equal(redditFeedURL('https://old.reddit.com/r/ClaudeAI/comments/1w48zcr/some_title/abc123/'),
'https://www.reddit.com/r/ClaudeAI/comments/1w48zcr/some_title/abc123/.rss');
assert.equal(redditFeedURL('https://reddit.com/comments/1w48zcr'), 'https://www.reddit.com/comments/1w48zcr/.rss');
assert.equal(redditFeedURL('https://www.reddit.com/r/ClaudeAI'), 'https://www.reddit.com/r/ClaudeAI/.rss');
assert.equal(redditFeedURL('https://www.reddit.com/r/ClaudeAI/top/?t=day'), 'https://www.reddit.com/r/ClaudeAI/top/.rss?t=day');
assert.equal(redditFeedURL('https://www.reddit.com/u/spez'), 'https://www.reddit.com/user/spez/.rss');
assert.equal(redditFeedURL('https://www.reddit.com/user/spez/'), 'https://www.reddit.com/user/spez/.rss');
assert.equal(redditFeedURL('https://www.reddit.com/search?q=claude+code'), 'https://www.reddit.com/search.rss?q=claude+code');
assert.equal(redditFeedURL('https://www.reddit.com/'), 'https://www.reddit.com/.rss');
// Already a feed, or a shape with no feed, or not reddit at all.
assert.equal(redditFeedURL('https://www.reddit.com/r/ClaudeAI/.rss'), null);
assert.equal(redditFeedURL('https://www.reddit.com/comments/1w48zcr/.rss'), null);
assert.equal(redditFeedURL('https://www.reddit.com/r/ClaudeAI/about/rules/'), null);
assert.equal(redditFeedURL('https://www.reddit.com/r/ClaudeAI/wiki/index'), null);
assert.equal(redditFeedURL('https://www.reddit.com/login'), null);
assert.equal(redditFeedURL('https://www.redditmedia.com/r/ClaudeAI/'), null);
assert.equal(redditFeedURL('https://reddit.com.example/r/ClaudeAI/'), null);
assert.equal(redditFeedURL('not a url'), null);
});

test('a refused firefox fingerprint on reddit.com falls back to chrome', () => withoutProxyEnv(async () => {
const impers = fakeImpers(['firefox']);
const page = await viaImpers(impers, 'https://www.reddit.com/r/ClaudeAI/.rss');
Expand Down