read Atom and RSS feeds: Stack Overflow answers through the open /feeds door

Sites behind hard bot challenges often leave their feeds open. distill()
now detects feed XML, converts entries (title, byline, link, escaped HTML
body) into a plain HTML document, and everything downstream is unchanged.
fetch accepts xml content types. Ships a stackoverflow.com spec mapping
question/tag/user onto the feed URLs.

Live: oc open on a Stack Overflow question feed renders ~493 tokens vs
~29k for the page HTML, HTTP 200 where the HTML page is challenged.
This commit is contained in:
only-cli
2026-08-18 10:36:36 -04:00
parent 858755f979
commit bb214dcf59
3 changed files with 62 additions and 3 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"domain": "stackoverflow.com",
"commands": {
"question": { "open": "https://stackoverflow.com/feeds/question/{id}", "args": ["id"] },
"tag": { "open": "https://stackoverflow.com/feeds/tag?tagnames={name}&sort=newest", "args": ["name"] },
"user": { "open": "https://stackoverflow.com/feeds/user/{id}", "args": ["id"] },
"recent": { "open": "https://stackoverflow.com/feeds" }
}
}
+52 -2
View File
@@ -35,7 +35,7 @@ const clean = (s) => s.replace(/\s+/g, ' ').trim();
* @returns {Page}
*/
export function distill(html, url = '') {
const { document } = parseHTML(html);
const { document } = parseHTML(feedToHTML(html) ?? html);
const title = clean(document.querySelector('title')?.textContent ?? '');
/** @type {Block[]} */
const blocks = [];
@@ -107,7 +107,7 @@ const bodyOf = (document) => document.querySelector('body') ?? document.document
* @param {string} html
*/
function cleanDocument(html) {
const { document } = parseHTML(html);
const { document } = parseHTML(feedToHTML(html) ?? html);
for (const tag of DROP) {
for (const el of [...document.querySelectorAll(tag)]) el.remove();
}
@@ -145,6 +145,56 @@ export function toHTML(html) {
return el ? el.innerHTML.trim() : '';
}
/**
* Sites behind hard bot challenges often leave their Atom or RSS feeds open:
* Stack Overflow challenges every HTML page but publishes full question and
* answer bodies under /feeds. A feed is XML with the real content escaped
* inside each entry, so this converts one into a plain HTML document and
* everything downstream stays unchanged. Returns null for non-feed input.
* @param {string} text
* @returns {string | null}
*/
export function feedToHTML(text) {
const head = text.slice(0, 2000);
if (!/<(feed|rss)[\s>]/i.test(head) || /<(html|body)[\s>]/i.test(head)) return null;
// RSS wraps bodies in CDATA, which an HTML parser reads as a comment and
// drops. Escaping the section turns it into ordinary text, the same shape
// Atom feeds already use.
const xml = text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_, inner) =>
inner.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'));
const { document } = parseHTML(xml);
const root = document.querySelector('feed, rss');
if (!root) return null;
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// Feed elements are unknown to the HTML parser, so self-closed ones like
// <category /> stay open and swallow their siblings. Descendant queries
// still land, because a closing </entry> or </item> pops the whole pile.
const field = (el, sel) => clean(el.querySelector(sel)?.textContent ?? '');
const feedTitle = field(root, 'title');
const parts = [];
for (const entry of root.querySelectorAll('entry, item')) {
const title = field(entry, 'title');
const href = entry.querySelector('link[rel="alternate"]')?.getAttribute('href')
?? entry.querySelector('link[href]')?.getAttribute('href')
?? field(entry, 'guid');
const author = field(entry, 'author name') || field(entry, 'author');
const date = (field(entry, 'updated') || field(entry, 'published') || field(entry, 'pubdate')).slice(0, 10);
const byline = [author && `by ${author}`, date].filter(Boolean).join(', ');
// Atom escapes the entry body, so textContent of content/summary is the
// 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>`);
}
parts.push(body, '</article>');
}
// A full skeleton, because linkedom treats the first element of a bare
// multi-rooted fragment as the whole document and drops its siblings.
return `<html><head><title>${esc(feedTitle)}</title></head><body>\n${parts.join('\n')}\n</body></html>`;
}
/**
* Adjacent text nodes arrive fragmented (one per inline element boundary).
* Merging them is what turns DOM noise into readable lines.
+1 -1
View File
@@ -61,7 +61,7 @@ async function viaFetch(target) {
throw new Error(`fetch failed: ${res.status} ${res.statusText} for ${target}`);
}
const type = res.headers.get('content-type') ?? '';
if (type && !type.includes('html')) {
if (type && !type.includes('html') && !type.includes('xml')) {
throw new Error(`not an HTML page (${type.split(';')[0]}), nothing to distill`);
}
return { url: res.url, html: await res.text(), status: res.status, via: 'fetch' };