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.
61 lines
2.3 KiB
TypeScript
61 lines
2.3 KiB
TypeScript
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;
|
|
}
|