mirror of
https://github.com/only-cli/oc.git
synced 2026-09-15 10:40:56 +02:00
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:
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>'));
|
||||
const { document } = parseHTML(xml);
|
||||
const root = document.querySelector('feed, rss');
|
||||
if (!root) return null;
|
||||
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
// 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
@@ -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' };
|
||||
|
||||
Reference in New Issue
Block a user