fix: judge an unreadable page by evidence, not by length alone

contentFailure called every render under 25 tokens a failure, which
made exit 2 claim a terse page was JavaScript-only, gated, or
challenged when it had simply arrived terse. The verdict now needs
evidence: nothing extracted is empty whatever the page weighed, and a
short render is only a failure when the markup behind it was far too
big to have carried only that. A status endpoint or a one-line answer
now exits 0, script-only shells and consent walls still exit 2, and
raw applies the same rule before refusing. The --json empty field
follows the same policy, so callers keep a machine-stable distinction
between empty and merely short.

Fixes #29
This commit is contained in:
only-cli
2026-08-24 13:59:27 -04:00
parent 8e645bc4ea
commit 886ce58e27
3 changed files with 27 additions and 7 deletions
+6 -2
View File
@@ -182,8 +182,12 @@ async function main() {
// Only the blank case here. `raw` is the fallback the compact view's
// failure line names, so it must not fail on the same pages: a page
// whose only text is its menu still has markup, and printing it is the
// whole point of `raw`.
if (outTokens < MIN_CONTENT) noContent(finalUrl, `~${outTokens} tokens of markdown`, '');
// whole point of `raw`. And a short page that arrived short is not
// blank, so the verdict needs the same evidence the compact view asks
// for: near-nothing distilled out of markup that promised more.
if (outTokens < MIN_CONTENT && contentFailure(outTokens, htmlTokens)) {
noContent(finalUrl, `~${outTokens} tokens of markdown`, '');
}
return;
}
const page = distill(html, finalUrl);
+8 -3
View File
@@ -35,8 +35,9 @@ const num = (v) => v.toLocaleString('en-US');
// which is what tells a link-list page (Hacker News, search results) from a
// page whose only links are its own menu.
const CONTENT_LABEL = 25;
// Below this there is nothing to read whatever the page is, so how much markup
// it arrived in does not matter.
// Below this a render is suspiciously thin, but thin is only a verdict when
// the page's own size says there should have been more. A terse page that
// arrived terse (a status endpoint, a one-line answer) distilled fine.
export const MIN_CONTENT = 25;
// Below this, with markup that large behind it, the fetch worked and the render
// did not: a real page of that weight always distills to more. A genuinely
@@ -65,7 +66,11 @@ export const contentTokens = (page) =>
* @returns {string|null}
*/
export function contentFailure(content, htmlTokens) {
if (content < MIN_CONTENT) return `~${content} tokens of text on the whole page`;
// Nothing extracted is empty whatever the page weighed. Anything more is
// only a failure with evidence: a small page that renders small is not
// gated, it is small, and exit 2 on it would send an agent to a browser
// for a page it was already holding.
if (content === 0) return 'no text on the whole page';
if (content < THIN_CONTENT && htmlTokens > THIN_HTML) {
return `~${content} tokens of text out of ~${htmlTokens} of HTML`;
}
+13 -2
View File
@@ -490,13 +490,13 @@ test('a page that arrives with no readable text is reported as a failure', () =>
};
// Nothing at all, whatever the page weighed.
assert.match(verdict('<div id="root"></div>', 0), /~0 tokens of text on the whole page/);
assert.match(verdict('<div id="root"></div>', 0), /no text on the whole page/);
// Menu links only: short labels are furniture, so this page has no content
// either, however much markup came with it.
const chrome = ['Help', 'Log in', 'Content Policy', 'About', 'Careers', 'Press']
.map((t) => `<a href="/${t}">${t}</a>`).join('');
assert.match(verdict(chrome, 60_000), /~0 tokens of text on the whole page/);
assert.match(verdict(chrome, 60_000), /no text on the whole page/);
// A consent wall or a login gate: a sentence or two of real text, out of
// markup far too big to have carried only that.
@@ -508,6 +508,17 @@ test('a page that arrives with no readable text is reported as a failure', () =>
assert.equal(verdict(gate, 0), null);
});
test('a terse page that arrived terse is content, not a failed render', () => {
// A status endpoint or a one-line answer distills fine and has to exit 0:
// calling it gated would send an agent to a browser for a page it was
// already holding. Only weight it never rendered is evidence of a gate.
const html = '<html><head><title>status</title></head><body><p>All systems operational.</p></body></html>';
const page = distill(html, 'https://fixture.test/status');
assert.equal(contentFailure(contentTokens(page), estimateTokens(html)), null);
const json = distill('{"status":"ok"}', 'https://fixture.test/health');
assert.equal(contentFailure(contentTokens(json), 4), null);
});
test('a link-list page counts as content even with no prose on it', () => {
// Hacker News and search results are links and nothing else, so a rule that
// counted only prose would call the tool's best pages empty.