Add #rss command family with LLM tags and summaries (v0.20.0)
CI / build + unit (pull_request) Successful in 1m15s
CI / build + unit (pull_request) Successful in 1m15s
#rss subcommands on the command channel: help / add <url> <name> / remove <name> / tags / sources / get <name|#tag>. Sources are stored in a new rss_sources table in the existing MESH_DB_PATH file (second node:sqlite connection, case-insensitive names); feeds parsed with fast-xml-parser (RSS 2.0 + Atom); AI tags and headline summaries via injected LlmService.chat() with bounded maxTokens (50/200). CommandService gains prefix registration so one handler owns the whole #rss family while #ping stays an exact match; wired in CommandModule's factory — still no constructor-level cycle, no forwardRef. One dead feed does not sink a tag summary.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
/** One feed item: title always present (non-empty), link where the feed provides one. */
|
||||
export interface FeedItem {
|
||||
title: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
const parser = new XMLParser({ ignoreAttributes: false });
|
||||
|
||||
function asText(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value.trim() || undefined;
|
||||
// element with attributes/CDATA parses to an object holding its text under #text
|
||||
const t = (value as Record<string, unknown>)?.['#text'];
|
||||
if (typeof t === 'string' && t.trim()) return t.trim();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asLink(value: unknown): string | undefined {
|
||||
const fromText = asText(value);
|
||||
if (fromText) return fromText; // RSS 2.0 <link>url</link>
|
||||
// Atom <link rel="alternate" href="..."/> — the URL is an attribute
|
||||
const href = (value as Record<string, unknown>)?.['@_href'];
|
||||
if (typeof href === 'string' && href.trim()) return href.trim();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collect(value: unknown): Record<string, unknown>[] {
|
||||
// a single item/entry parses to an object, several to an array
|
||||
if (!value) return [];
|
||||
const arr = Array.isArray(value) ? value : [value];
|
||||
return arr.filter(
|
||||
(v): v is Record<string, unknown> => !!v && typeof v === 'object',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RSS 2.0 (`<item><title>/<link>`) or Atom (`<entry><title>`) XML into items with
|
||||
* non-empty titles, in document order (feeds are newest-first). Returns [] for malformed
|
||||
* input or feeds without usable items — that empty result IS the "not a valid feed" signal.
|
||||
*/
|
||||
export function parseFeed(xml: string): FeedItem[] {
|
||||
let doc: unknown;
|
||||
try {
|
||||
doc = parser.parse(xml);
|
||||
} catch {
|
||||
return []; // fast-xml-parser is lenient, but a hard failure means no items either
|
||||
}
|
||||
const root = doc as Record<string, any>;
|
||||
// coalesce the RAW values first — collect() always returns an array, so `collect(a) ?? collect(b)` could never fall through to b
|
||||
const rawItems = collect(root?.rss?.channel?.item ?? root?.feed?.entry);
|
||||
const items: FeedItem[] = [];
|
||||
for (const item of rawItems) {
|
||||
const title = asText(item?.title);
|
||||
if (!title) continue; // skip entries without a usable title
|
||||
const link = asLink(item.link);
|
||||
items.push(link ? { title, link } : { title });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
Reference in New Issue
Block a user