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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
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

- Requests to reddit.com present the Firefox fingerprint first and fall back
to Chrome, the reverse of every other site. Reddit's edge answers the Chrome
fingerprint with a 403 or a 429 while letting Firefox through, and since it
allows anonymous readers about ten requests a minute per address, the wasted
Chrome attempt was costing a real share of that budget on every read (#52).

## 0.5.2

### 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. The feeds carry titles, authors, dates, and bodies but no scores or comment counts, and anonymous reddit.com allows roughly ten requests a minute per address, so a burst of Reddit shortcuts ends in a 429 that takes minutes to clear. 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 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 allows roughly ten requests a minute per address, so a burst of Reddit shortcuts ends in a 429 that takes minutes to clear. 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
37 changes: 31 additions & 6 deletions src/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,29 @@ function captureSetCookie(jar, url, res) {
jar.storeFromResponse(url, getSetCookieHeaders(res));
}

// Hosts whose edge answers the chrome fingerprint with a 403 or a 429 while
// letting firefox through. reddit.com started doing this in 2026 (#52), so
// starting with chrome there would turn every read into two requests against
// a per-address rate limit of about ten a minute. Subdomains inherit the
// entry.
const FIREFOX_FIRST_HOSTS = ['reddit.com'];

/**
* The order in which impers identities are tried for a URL.
* @param {string} target
* @returns {['chrome', 'firefox'] | ['firefox', 'chrome']}
*/
export function identityOrder(target) {
let host = '';
try {
host = new URL(target).hostname.toLowerCase();
} catch {
return ['chrome', 'firefox'];
}
const firefoxFirst = FIREFOX_FIRST_HOSTS.some((h) => host === h || host.endsWith(`.${h}`));
return firefoxFirst ? ['firefox', 'chrome'] : ['chrome', 'firefox'];
}

/**
* Fetch a page through impers, downgrading identity when one is refused.
* Exported so the downgrade chain can be proven against a fake impers; the
Expand All @@ -565,14 +588,15 @@ function captureSetCookie(jar, url, res) {
* @returns {Promise<{url: string, html: string, status: number, via: string}>}
*/
export async function viaImpers(impers, target, jar) {
// Some sites (Reddit) 403 the chrome fingerprint but accept firefox, so a
// blocked first attempt gets one cheap retry with a second identity. An
// A blocked first attempt gets one cheap retry with the other identity. An
// ImpersonateError is the same story one layer down: impers resolves the
// 'chrome' alias to its newest fingerprint, but the native library it loads
// can be an older system copy of libcurl-impersonate that predates that
// fingerprint and refuses it before any request leaves. Firefox aliases to
// an older target that such a library usually still knows, and when both
// identities are refused the plain fetch transport still gets the page.
// Hosts that are known to refuse chrome outright start with firefox, so the
// usual case there costs one request instead of a 403 and a retry.
const asking = (impersonate) => (url) =>
impers.get(url, {
impersonate,
Expand All @@ -590,11 +614,12 @@ export async function viaImpers(impers, target, jar) {
return null;
}
};
let via = 'impers:chrome';
let got = await attempt('chrome');
const [first, second] = identityOrder(target);
let via = `impers:${first}`;
let got = await attempt(first);
if (!got || got.status >= 400) {
via = 'impers:firefox';
got = (await attempt('firefox')) ?? got;
via = `impers:${second}`;
got = (await attempt(second)) ?? got;
}
if (!got) return viaFetch(target, jar);
const { res, status } = got;
Expand Down
29 changes: 28 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, resolveProxy, proxyGet, viaImpers } = await import('../src/fetch.js');
const { fetchPage, followRedirects, identityOrder, resolveProxy, proxyGet, viaImpers } = await import('../src/fetch.js');

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

Expand Down Expand Up @@ -791,6 +791,33 @@ test('when both identities are refused the page still arrives via plain fetch',
}
});

test('reddit.com is asked with the firefox fingerprint first', () => withoutProxyEnv(async () => {
// Reddit's edge answers the chrome fingerprint with a 403 or a 429 while
// letting firefox through (#52), and it rate-limits anonymous readers per
// address, so a wasted chrome attempt there is a real cost, not a retry.
assert.deepEqual(identityOrder('https://www.reddit.com/r/ClaudeAI/.rss'), ['firefox', 'chrome']);
assert.deepEqual(identityOrder('https://old.reddit.com/r/ClaudeAI/'), ['firefox', 'chrome']);
assert.deepEqual(identityOrder('https://reddit.com/'), ['firefox', 'chrome']);
assert.deepEqual(identityOrder('https://notreddit.com/'), ['chrome', 'firefox']);
assert.deepEqual(identityOrder('https://reddit.com.example/'), ['chrome', 'firefox']);
assert.deepEqual(identityOrder('https://news.ycombinator.com/'), ['chrome', 'firefox']);
assert.deepEqual(identityOrder('not a url'), ['chrome', 'firefox']);

const impers = fakeImpers([]);
const page = await viaImpers(impers, 'https://www.reddit.com/r/ClaudeAI/.rss');
assert.deepEqual(impers.identities, ['firefox']);
assert.equal(page.via, 'impers:firefox');
assert.equal(page.status, 200);
}));

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');
assert.deepEqual(impers.identities, ['firefox', 'chrome']);
assert.equal(page.via, 'impers:chrome');
assert.equal(page.status, 200);
}));

test('only an ImpersonateError downgrades; other impers failures propagate', () => withoutProxyEnv(async () => {
const impers = {
get: () => Promise.reject(new Error('connection reset')),
Expand Down