Add feed discovery plugin

This commit is contained in:
Anthony Ettinger 2026-06-09 09:34:31 +00:00
parent e47616bf9d
commit 5cfeea6b57
37 changed files with 2432 additions and 5 deletions

View file

@ -0,0 +1,34 @@
import type { FeedDiscoveryConfig } from "./types.js";
const DEFAULT_TIMEOUT_MS = 8_000;
const DEFAULT_MAX_BODY_BYTES = 1_000_000;
export function readFeedDiscoveryConfig(env: NodeJS.ProcessEnv = process.env): FeedDiscoveryConfig {
return {
cacheTtlSeconds: readNumber(env.LOGICSRC_FEEDS_CACHE_TTL_SECONDS, 86_400),
maxProviders: readNumber(env.LOGICSRC_FEEDS_MAX_PROVIDERS, 10),
maxProbes: readNumber(env.LOGICSRC_FEEDS_MAX_PROBES, 50),
requestTimeoutMs: readNumber(env.LOGICSRC_FEEDS_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
maxBodyBytes: readNumber(env.LOGICSRC_FEEDS_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES),
userAgent: env.LOGICSRC_FEEDS_USER_AGENT || "LogicSrcFeedDiscovery/0.1",
opmlPaths: splitList(env.LOGICSRC_FEEDS_OPML_PATHS),
candidateUrls: splitList(env.LOGICSRC_FEEDS_CANDIDATE_URLS),
podcastIndexApiKey: env.PODCASTINDEX_API_KEY,
podcastIndexApiSecret: env.PODCASTINDEX_API_SECRET
};
}
function readNumber(value: string | undefined, fallback: number) {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function splitList(value: string | undefined) {
return (value ?? "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}

View file

@ -0,0 +1,62 @@
create table if not exists feed_sources (
id uuid primary key default gen_random_uuid(),
title text not null,
description text,
homepage_url text,
feed_url text not null unique,
canonical_feed_url text,
kind text not null default 'unknown',
provider text not null,
language text,
image_url text,
score numeric not null default 0,
confidence numeric not null default 0,
freshness_score numeric not null default 0,
keyword_score numeric not null default 0,
provider_score numeric not null default 0,
last_published_at timestamptz,
last_checked_at timestamptz,
is_valid boolean default true,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create table if not exists feed_discovery_queries (
id uuid primary key default gen_random_uuid(),
query text not null,
normalized_query text not null,
type text default 'all',
result_count integer not null default 0,
created_at timestamptz default now()
);
create table if not exists feed_discovery_results (
query_id uuid references feed_discovery_queries(id) on delete cascade,
source_id uuid references feed_sources(id) on delete cascade,
rank integer not null,
score numeric not null,
created_at timestamptz default now(),
primary key (query_id, source_id)
);
create table if not exists feed_items (
id uuid primary key default gen_random_uuid(),
source_id uuid references feed_sources(id) on delete cascade,
title text not null,
url text not null unique,
description text,
published_at timestamptz,
guid text,
created_at timestamptz default now()
);
create table if not exists feed_provider_logs (
id uuid primary key default gen_random_uuid(),
provider text not null,
query text not null,
status text not null,
result_count integer default 0,
error_message text,
duration_ms integer,
created_at timestamptz default now()
);

View file

@ -0,0 +1,38 @@
import type { DiscoveredFeed } from "./types.js";
import { canonicalizeUrl } from "./url-safety.js";
export function dedupeFeeds(feeds: DiscoveredFeed[]) {
const byKey = new Map<string, DiscoveredFeed>();
for (const feed of feeds) {
const key = dedupeKey(feed);
const existing = byKey.get(key);
if (!existing || feed.score > existing.score || feed.confidence > existing.confidence) {
byKey.set(key, mergeFeed(existing, feed));
}
}
return [...byKey.values()].sort((a, b) => b.score - a.score || b.confidence - a.confidence);
}
function dedupeKey(feed: DiscoveredFeed) {
const feedUrl = feed.canonicalFeedUrl || feed.feedUrl;
try {
return `feed:${canonicalizeUrl(feedUrl)}`;
} catch {
return `feed:${feedUrl.toLowerCase()}`;
}
}
function mergeFeed(existing: DiscoveredFeed | undefined, next: DiscoveredFeed): DiscoveredFeed {
if (!existing) {
return next;
}
return {
...existing,
...next,
tags: [...new Set([...existing.tags, ...next.tags])],
sampleItems: next.sampleItems?.length ? next.sampleItems : existing.sampleItems
};
}

View file

@ -0,0 +1,120 @@
import slugify from "slugify";
import { readFeedDiscoveryConfig } from "./config.js";
import { dedupeFeeds } from "./dedupe.js";
import { createDefaultFeedProviders } from "./providers/index.js";
import { scoreFeed } from "./scoring.js";
import type { DiscoveredFeed, FeedDiscoveryConfig, FeedDiscoveryProvider, FeedDiscoveryQuery, FeedDiscoveryResponse } from "./types.js";
import { canonicalizeUrl } from "./url-safety.js";
import { validateFeed } from "./validate-feed.js";
export async function discoverFeeds(query: FeedDiscoveryQuery, options: { providers?: FeedDiscoveryProvider[]; config?: Partial<FeedDiscoveryConfig> } = {}): Promise<FeedDiscoveryResponse> {
const config = { ...readFeedDiscoveryConfig(), ...options.config };
const normalizedQuery = normalizeQuery(query.q);
const resolvedQuery: FeedDiscoveryQuery = {
...query,
q: query.q.trim(),
type: query.type ?? "all",
limit: clampLimit(query.limit),
includeUnvalidated: query.includeUnvalidated ?? false,
includeDeadFeeds: query.includeDeadFeeds ?? false
};
const providers = (options.providers ?? createDefaultFeedProviders(config))
.filter((provider) => provider.enabledByDefault || provider.id === "podcastindex")
.filter((provider) => !resolvedQuery.providers?.length || resolvedQuery.providers.includes(provider.id))
.slice(0, config.maxProviders);
const settled = await Promise.all(providers.map(async (provider) => {
try {
return {
provider: provider.id,
results: await provider.search(resolvedQuery),
error: undefined
};
} catch (error) {
return {
provider: provider.id,
results: [],
error: error instanceof Error ? error.message : String(error)
};
}
}));
const providerErrors: FeedDiscoveryResponse["providerErrors"] = [];
const candidates: DiscoveredFeed[] = [];
for (const result of settled) {
if (result.error) {
providerErrors.push({ provider: result.provider, error: result.error });
continue;
}
candidates.push(...result.results.map((feed) => ({ ...feed, provider: feed.provider || result.provider })));
}
const validated = await validateCandidates(candidates.slice(0, Math.max((resolvedQuery.limit ?? 25) * 2, 25)), resolvedQuery, config);
const scored = validated
.filter((feed) => !resolvedQuery.type || resolvedQuery.type === "all" || feed.kind === resolvedQuery.type)
.map((feed) => scoreFeed(feed, resolvedQuery));
const results = dedupeFeeds(scored).slice(0, resolvedQuery.limit ?? 25);
return {
query: query.q,
normalizedQuery,
count: results.length,
providerErrors,
results
};
}
async function validateCandidates(candidates: DiscoveredFeed[], query: FeedDiscoveryQuery, config: FeedDiscoveryConfig) {
if (query.includeUnvalidated) {
return candidates.map((feed) => ({
...feed,
canonicalFeedUrl: safeCanonical(feed.feedUrl),
isValid: feed.isValid ?? false,
validationScore: feed.validationScore ?? 0.4
}));
}
const feeds: DiscoveredFeed[] = [];
for (const candidate of candidates) {
const validation = await validateFeed(candidate.feedUrl, config);
if (!validation.ok && !query.includeDeadFeeds) {
continue;
}
feeds.push({
...candidate,
title: validation.title ?? candidate.title,
description: validation.description ?? candidate.description,
homepageUrl: validation.homepageUrl ?? candidate.homepageUrl,
canonicalFeedUrl: validation.canonicalFeedUrl ?? safeCanonical(candidate.feedUrl),
kind: validation.ok && validation.kind !== "unknown" ? validation.kind : candidate.kind,
language: validation.language ?? candidate.language,
imageUrl: validation.imageUrl ?? candidate.imageUrl,
lastPublishedAt: validation.lastPublishedAt ?? candidate.lastPublishedAt,
sampleItems: validation.sampleItems.length ? validation.sampleItems : candidate.sampleItems,
isValid: validation.ok,
validationScore: validation.ok ? 1 : 0.2
});
}
return feeds;
}
function normalizeQuery(query: string) {
return slugify(query.trim().toLowerCase(), { lower: true, strict: true }) || "feeds";
}
function clampLimit(limit: number | undefined) {
if (!limit) {
return 25;
}
return Math.min(Math.max(Math.trunc(limit), 1), 100);
}
function safeCanonical(url: string) {
try {
return canonicalizeUrl(url);
} catch {
return url;
}
}

View file

@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { dedupeFeeds } from "./dedupe.js";
import { discoverFeeds } from "./discovery.js";
import { parseFeedDocument } from "./feed-parsing.js";
import { renderDiscoveryOutput } from "./output/index.js";
import { extractAlternateFeedLinks } from "./probe-site.js";
import { scoreFeed } from "./scoring.js";
import type { DiscoveredFeed, FeedDiscoveryProvider } from "./types.js";
import { assertSafeHttpUrl } from "./url-safety.js";
const baseFeed: DiscoveredFeed = {
title: "MicroSaaS Ideas",
description: "Ideas for indie SaaS founders",
homepageUrl: "https://example.com",
feedUrl: "https://example.com/feed.xml",
kind: "blog",
provider: "manual-curated",
score: 0,
confidence: 0.5,
tags: ["microsaas", "saas"]
};
describe("feed parsing", () => {
it("parses RSS feeds with sample items", () => {
const result = parseFeedDocument("https://example.com/rss.xml", `<?xml version="1.0"?><rss version="2.0"><channel><title>MicroSaaS Ideas</title><link>https://example.com</link><item><title>Launch tiny products</title><link>https://example.com/post</link><pubDate>Tue, 09 Jun 2026 00:00:00 GMT</pubDate></item></channel></rss>`);
expect(result.ok).toBe(true);
expect(result.title).toBe("MicroSaaS Ideas");
expect(result.sampleItems[0]).toMatchObject({ title: "Launch tiny products", url: "https://example.com/post" });
});
it("parses Atom and JSON Feed documents", () => {
const atom = parseFeedDocument("https://example.com/atom.xml", `<feed><title>Atom Feed</title><entry><title>Entry</title><updated>2026-06-09T00:00:00Z</updated></entry></feed>`);
const json = parseFeedDocument("https://example.com/feed.json", JSON.stringify({ version: "https://jsonfeed.org/version/1.1", title: "JSON Feed", items: [{ title: "Item", url: "https://example.com/item" }] }), "application/feed+json");
expect(atom.ok).toBe(true);
expect(json.ok).toBe(true);
});
});
describe("site probing helpers", () => {
it("extracts alternate feed links", () => {
const links = extractAlternateFeedLinks(
`<html><head><link rel="alternate" type="application/rss+xml" href="/rss.xml"><link rel="alternate" type="text/html" href="/plain"></head></html>`,
"https://example.com/blog"
);
expect(links).toEqual(["https://example.com/rss.xml"]);
});
it("blocks private SSRF targets", async () => {
await expect(assertSafeHttpUrl("http://127.0.0.1/feed")).rejects.toThrow(/Blocked internal/);
await expect(assertSafeHttpUrl("http://[::ffff:192.168.1.10]/feed")).rejects.toThrow(/Blocked internal/);
await expect(assertSafeHttpUrl("file:///etc/passwd")).rejects.toThrow(/Unsupported URL protocol/);
});
});
describe("scoring, dedupe, and output", () => {
it("scores relevant fresh feeds and dedupes canonical URLs", () => {
const scored = scoreFeed({ ...baseFeed, lastPublishedAt: new Date().toISOString() }, { q: "microsaas" });
const duplicate = { ...scored, feedUrl: "https://example.com/feed.xml?utm_source=test", score: scored.score - 0.1 };
expect(scored.score).toBeGreaterThan(0.5);
expect(dedupeFeeds([duplicate, scored])).toHaveLength(1);
});
it("renders JSON, OPML, and RSS outputs", () => {
const response = { query: "microsaas", normalizedQuery: "microsaas", count: 1, providerErrors: [], results: [scoreFeed(baseFeed, { q: "microsaas" })] };
expect(JSON.parse(renderDiscoveryOutput(response, "json"))).toMatchObject({ count: 1 });
expect(renderDiscoveryOutput(response, "opml")).toContain("<opml");
expect(renderDiscoveryOutput(response, "rss")).toContain("<rss");
});
});
describe("discovery orchestration", () => {
it("isolates provider failures", async () => {
const okProvider: FeedDiscoveryProvider = {
id: "ok-provider",
name: "OK",
enabledByDefault: true,
requiresApiKey: false,
async search() {
return [baseFeed];
}
};
const failingProvider: FeedDiscoveryProvider = {
id: "broken-provider",
name: "Broken",
enabledByDefault: true,
requiresApiKey: false,
async search() {
throw new Error("provider unavailable");
}
};
const response = await discoverFeeds({ q: "microsaas", includeUnvalidated: true }, { providers: [failingProvider, okProvider] });
expect(response.providerErrors).toEqual([{ provider: "broken-provider", error: "provider unavailable" }]);
expect(response.results).toHaveLength(1);
});
});

View file

@ -0,0 +1,204 @@
import { XMLParser } from "fast-xml-parser";
import type { FeedKind, FeedSampleItem, ValidationResult } from "./types.js";
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "",
textNodeName: "text",
cdataPropName: "text",
removeNSPrefix: true
});
export function parseFeedDocument(feedUrl: string, body: string, contentType = ""): ValidationResult {
const trimmed = body.trim();
if (!trimmed) {
return invalid(feedUrl, "Empty feed body");
}
if (contentType.includes("json") || trimmed.startsWith("{")) {
return parseJsonFeed(feedUrl, trimmed);
}
try {
const document = parser.parse(trimmed) as unknown;
if (!isRecord(document)) {
return invalid(feedUrl, "Feed did not parse as an object");
}
if (isRecord(document.rss)) {
return parseRss(feedUrl, document.rss);
}
if (isRecord(document.feed)) {
return parseAtom(feedUrl, document.feed);
}
return invalid(feedUrl, "Document is not RSS, Atom, or JSON Feed");
} catch (error) {
return invalid(feedUrl, error instanceof Error ? error.message : String(error));
}
}
function parseJsonFeed(feedUrl: string, body: string): ValidationResult {
try {
const document = JSON.parse(body) as unknown;
if (!isRecord(document) || typeof document.version !== "string" || !document.version.includes("jsonfeed")) {
return invalid(feedUrl, "JSON document is not JSON Feed");
}
const items = asArray(document.items).filter(isRecord);
const sampleItems = items.slice(0, 5).map((item) => ({
title: stringValue(item.title) || stringValue(item.url) || "Untitled item",
url: stringValue(item.url),
publishedAt: stringValue(item.date_published),
description: stringValue(item.summary) || stringValue(item.content_text)
}));
const title = stringValue(document.title);
if (!title && sampleItems.length === 0) {
return invalid(feedUrl, "Feed has no title or items");
}
return {
ok: true,
feedUrl,
canonicalFeedUrl: feedUrl,
title: title || "Untitled JSON Feed",
description: stringValue(document.description),
homepageUrl: stringValue(document.home_page_url),
kind: "blog",
imageUrl: stringValue(document.icon) || stringValue(document.favicon),
lastPublishedAt: newestDate(sampleItems.map((item) => item.publishedAt)),
sampleItems
};
} catch (error) {
return invalid(feedUrl, error instanceof Error ? error.message : String(error));
}
}
function parseRss(feedUrl: string, rss: Record<string, unknown>): ValidationResult {
const channel = isRecord(rss.channel) ? rss.channel : rss;
const items = asArray(channel.item).filter(isRecord);
const sampleItems = items.slice(0, 5).map((item) => ({
title: stringValue(item.title) || stringValue(item.guid) || "Untitled item",
url: linkValue(item.link) || stringValue(item.guid),
publishedAt: stringValue(item.pubDate) || stringValue(item.published) || stringValue(item.updated),
description: stringValue(item.description)
}));
const title = stringValue(channel.title);
if (!title && sampleItems.length === 0) {
return invalid(feedUrl, "RSS feed has no title or items");
}
return {
ok: true,
feedUrl,
canonicalFeedUrl: feedUrl,
title: title || "Untitled RSS Feed",
description: stringValue(channel.description),
homepageUrl: linkValue(channel.link),
kind: detectRssKind(channel),
language: stringValue(channel.language),
imageUrl: imageValue(channel.image),
lastPublishedAt: newestDate([stringValue(channel.lastBuildDate), stringValue(channel.pubDate), ...sampleItems.map((item) => item.publishedAt)]),
sampleItems
};
}
function parseAtom(feedUrl: string, feed: Record<string, unknown>): ValidationResult {
const entries = asArray(feed.entry).filter(isRecord);
const sampleItems = entries.slice(0, 5).map((entry) => ({
title: stringValue(entry.title) || "Untitled item",
url: atomLink(entry.link),
publishedAt: stringValue(entry.published) || stringValue(entry.updated),
description: stringValue(entry.summary) || stringValue(entry.content)
}));
const title = stringValue(feed.title);
if (!title && sampleItems.length === 0) {
return invalid(feedUrl, "Atom feed has no title or entries");
}
return {
ok: true,
feedUrl,
canonicalFeedUrl: feedUrl,
title: title || "Untitled Atom Feed",
description: stringValue(feed.subtitle),
homepageUrl: atomLink(feed.link),
kind: "blog",
language: stringValue(feed.lang),
lastPublishedAt: newestDate([stringValue(feed.updated), ...sampleItems.map((item) => item.publishedAt)]),
sampleItems
};
}
function detectRssKind(channel: Record<string, unknown>): FeedKind {
if (channel.itunes || channel["itunes:author"] || channel.enclosure) {
return "podcast";
}
return "blog";
}
function imageValue(value: unknown) {
if (isRecord(value)) {
return stringValue(value.url);
}
return undefined;
}
function atomLink(value: unknown) {
const links = asArray(value).filter(isRecord);
const alternate = links.find((link) => stringValue(link.rel) === "alternate") ?? links[0];
return alternate ? stringValue(alternate.href) : stringValue(value);
}
function linkValue(value: unknown) {
if (isRecord(value)) {
return stringValue(value.href) || stringValue(value.text);
}
return stringValue(value);
}
function stringValue(value: unknown) {
if (typeof value === "string") {
return value.trim() || undefined;
}
if (typeof value === "number") {
return String(value);
}
if (isRecord(value) && typeof value.text === "string") {
return value.text.trim() || undefined;
}
return undefined;
}
function newestDate(values: Array<string | undefined>) {
const timestamps = values
.map((value) => (value ? Date.parse(value) : NaN))
.filter((value) => Number.isFinite(value));
if (timestamps.length === 0) {
return undefined;
}
return new Date(Math.max(...timestamps)).toISOString();
}
function invalid(feedUrl: string, error: string): ValidationResult {
return {
ok: false,
feedUrl,
kind: "unknown",
sampleItems: [],
error
};
}
function asArray(value: unknown): unknown[] {
if (Array.isArray(value)) {
return value;
}
return value === undefined || value === null ? [] : [value];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -0,0 +1,80 @@
import type { FeedDiscoveryConfig } from "./types.js";
import { assertSafeHttpUrl } from "./url-safety.js";
export interface BoundedFetchResult {
url: string;
contentType: string;
body: string;
status: number;
}
export async function fetchTextWithGuards(input: string, config: Pick<FeedDiscoveryConfig, "requestTimeoutMs" | "maxBodyBytes" | "userAgent">, redirects = 3): Promise<BoundedFetchResult> {
const url = await assertSafeHttpUrl(input);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
try {
const response = await fetch(url, {
redirect: "manual",
signal: controller.signal,
headers: {
"user-agent": config.userAgent,
accept: "application/rss+xml, application/atom+xml, application/feed+json, application/json, text/xml, application/xml, text/html;q=0.8, */*;q=0.5"
}
});
if (isRedirect(response.status)) {
if (redirects <= 0) {
throw new Error("Too many redirects");
}
const location = response.headers.get("location");
if (!location) {
throw new Error("Redirect without location");
}
return fetchTextWithGuards(new URL(location, url).toString(), config, redirects - 1);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const body = await readLimitedText(response, config.maxBodyBytes);
return {
url: response.url || url.toString(),
contentType: response.headers.get("content-type") ?? "",
body,
status: response.status
};
} finally {
clearTimeout(timeout);
}
}
async function readLimitedText(response: Response, maxBodyBytes: number) {
if (!response.body) {
return response.text();
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
received += value.byteLength;
if (received > maxBodyBytes) {
await reader.cancel();
throw new Error(`Response exceeded ${maxBodyBytes} bytes`);
}
chunks.push(value);
}
return new TextDecoder().decode(Buffer.concat(chunks));
}
function isRedirect(status: number) {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}

View file

@ -0,0 +1,47 @@
import type { PluginDefinition } from "@logicsrc/plugin-core";
import { readFeedDiscoveryConfig } from "./config.js";
import { discoverFeeds } from "./discovery.js";
import { feedDiscoveryManifest } from "./manifest.js";
import { probeSite } from "./probe-site.js";
import { providerManifests } from "./providers/index.js";
import { validateFeed } from "./validate-feed.js";
export const feedDiscoveryPlugin: PluginDefinition = {
manifest: feedDiscoveryManifest,
configDefaults: {
enabled: true,
cache_ttl_seconds: "${LOGICSRC_FEEDS_CACHE_TTL_SECONDS}",
max_providers: "${LOGICSRC_FEEDS_MAX_PROVIDERS}",
max_probes: "${LOGICSRC_FEEDS_MAX_PROBES}",
request_timeout_ms: "${LOGICSRC_FEEDS_REQUEST_TIMEOUT_MS}",
user_agent: "${LOGICSRC_FEEDS_USER_AGENT}"
},
routes: [
{ method: "GET", path: "/api/feeds/discover", capability: "feeds.discover" },
{ method: "GET", path: "/api/feeds/providers", capability: "feeds.providers.list" },
{ method: "GET", path: "/rss/discover/:keyword.xml", capability: "feeds.export.rss" }
],
permissions: ["feeds:discover", "feeds:validate", "feeds:probe", "feeds:export"],
tuiPanels: [{ id: "feed-discovery-status", title: "Feed Discovery" }]
};
export function listFeedProviders() {
return providerManifests(readFeedDiscoveryConfig());
}
export { discoverFeeds, feedDiscoveryManifest, probeSite, readFeedDiscoveryConfig, validateFeed };
export type {
DiscoveredFeed,
FeedDiscoveryConfig,
FeedDiscoveryProvider,
FeedDiscoveryQuery,
FeedDiscoveryResponse,
FeedKind,
FeedOutputFormat,
FeedProviderManifest,
FeedSampleItem,
ProbeResult,
ValidationResult
} from "./types.js";
export { renderAtom, renderDiscoveryOutput, renderJsonFeed, renderOpml, renderRss } from "./output/index.js";
export { createDefaultFeedProviders, providerManifests } from "./providers/index.js";

View file

@ -0,0 +1,27 @@
import type { PluginManifest } from "@logicsrc/plugin-core";
export const feedDiscoveryManifest: PluginManifest = {
id: "feed-discovery",
name: "Feed Discovery",
version: "0.1.0",
type: ["feeds", "rss", "discovery", "content"],
default: true,
capabilities: [
"feeds.discover",
"feeds.validate",
"feeds.probe",
"feeds.export.opml",
"feeds.export.rss",
"feeds.providers.list"
],
commands: ["feeds"],
env: [
"LOGICSRC_FEEDS_CACHE_TTL_SECONDS",
"LOGICSRC_FEEDS_MAX_PROVIDERS",
"LOGICSRC_FEEDS_MAX_PROBES",
"LOGICSRC_FEEDS_REQUEST_TIMEOUT_MS",
"LOGICSRC_FEEDS_USER_AGENT",
"PODCASTINDEX_API_KEY",
"PODCASTINDEX_API_SECRET"
]
};

View file

@ -0,0 +1,26 @@
import type { FeedDiscoveryResponse } from "../types.js";
import { escapeXml } from "./opml.js";
export function renderAtom(response: FeedDiscoveryResponse) {
const updated = new Date().toISOString();
const entries = response.results
.map((result) => {
const id = result.canonicalFeedUrl ?? result.feedUrl;
return ` <entry>
<id>${escapeXml(id)}</id>
<title>${escapeXml(result.title)}</title>
<link href="${escapeXml(result.homepageUrl ?? result.feedUrl)}" />
<updated>${escapeXml(result.lastPublishedAt ?? updated)}</updated>
<summary>${escapeXml(result.description ?? `Discovered ${result.kind} feed from ${result.provider}`)}</summary>
</entry>`;
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>logicsrc:feed-discovery:${escapeXml(response.normalizedQuery)}</id>
<title>LogicSRC feed discovery: ${escapeXml(response.query)}</title>
<updated>${updated}</updated>
${entries}
</feed>`;
}

View file

@ -0,0 +1,23 @@
import type { FeedDiscoveryResponse, FeedOutputFormat } from "../types.js";
import { renderAtom } from "./atom.js";
import { renderJsonFeed } from "./json-feed.js";
import { renderOpml } from "./opml.js";
import { renderRss } from "./rss.js";
export function renderDiscoveryOutput(response: FeedDiscoveryResponse, format: FeedOutputFormat) {
if (format === "opml") {
return renderOpml(response);
}
if (format === "rss") {
return renderRss(response);
}
if (format === "atom") {
return renderAtom(response);
}
if (format === "json-feed") {
return renderJsonFeed(response);
}
return JSON.stringify(response, null, 2);
}
export { renderAtom, renderJsonFeed, renderOpml, renderRss };

View file

@ -0,0 +1,22 @@
import type { FeedDiscoveryResponse } from "../types.js";
export function renderJsonFeed(response: FeedDiscoveryResponse) {
return JSON.stringify(
{
version: "https://jsonfeed.org/version/1.1",
title: `LogicSRC feed discovery: ${response.query}`,
home_page_url: "https://bittorrented.com/rss",
feed_url: `https://bittorrented.com/json-feed/discover/${encodeURIComponent(response.normalizedQuery)}.json`,
items: response.results.map((result) => ({
id: result.canonicalFeedUrl ?? result.feedUrl,
url: result.homepageUrl ?? result.feedUrl,
title: result.title,
summary: result.description ?? `Discovered ${result.kind} feed from ${result.provider}`,
date_published: result.lastPublishedAt,
tags: [result.kind, result.provider, ...result.tags]
}))
},
null,
2
);
}

View file

@ -0,0 +1,27 @@
import type { DiscoveredFeed, FeedDiscoveryResponse } from "../types.js";
export function renderOpml(response: FeedDiscoveryResponse) {
const outlines = response.results.map(renderOutline).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
<head>
<title>LogicSRC feed discovery: ${escapeXml(response.query)}</title>
</head>
<body>
${outlines}
</body>
</opml>`;
}
function renderOutline(feed: DiscoveredFeed) {
return ` <outline text="${escapeXml(feed.title)}" title="${escapeXml(feed.title)}" type="rss" xmlUrl="${escapeXml(feed.canonicalFeedUrl ?? feed.feedUrl)}"${feed.homepageUrl ? ` htmlUrl="${escapeXml(feed.homepageUrl)}"` : ""} category="${escapeXml(feed.kind)}" />`;
}
export function escapeXml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}

View file

@ -0,0 +1,25 @@
import RSS from "rss";
import type { FeedDiscoveryResponse } from "../types.js";
export function renderRss(response: FeedDiscoveryResponse) {
const feed = new RSS({
title: `LogicSRC feed discovery: ${response.query}`,
description: `Discovered feed sources for ${response.query}`,
feed_url: `https://bittorrented.com/rss/discover/${encodeURIComponent(response.normalizedQuery)}.xml`,
site_url: "https://bittorrented.com/rss",
language: "en"
});
for (const result of response.results) {
feed.item({
title: result.title,
description: result.description ?? `Discovered ${result.kind} feed from ${result.provider}`,
url: result.homepageUrl ?? result.feedUrl,
guid: result.canonicalFeedUrl ?? result.feedUrl,
categories: [result.kind, result.provider, ...result.tags],
date: result.lastPublishedAt ? new Date(result.lastPublishedAt) : new Date()
});
}
return feed.xml({ indent: true });
}

View file

@ -0,0 +1,93 @@
import * as cheerio from "cheerio";
import { readFeedDiscoveryConfig } from "./config.js";
import { fetchTextWithGuards } from "./http.js";
import type { DiscoveredFeed, FeedDiscoveryConfig, ProbeResult } from "./types.js";
import { canonicalizeUrl } from "./url-safety.js";
import { validateFeed } from "./validate-feed.js";
const COMMON_FEED_PATHS = [
"/feed",
"/rss",
"/rss.xml",
"/atom.xml",
"/index.xml",
"/feed.xml",
"/blog/feed",
"/blog/rss.xml",
"/news/feed",
"/posts/feed"
];
const FEED_MIME_PATTERN = /(rss|atom|feed\+json|xml)/i;
export async function probeSite(homepageUrl: string, config: Partial<FeedDiscoveryConfig> = {}): Promise<ProbeResult> {
const resolvedConfig = { ...readFeedDiscoveryConfig(), ...config };
const errors: string[] = [];
const candidates = new Set<string>();
const canonicalHomepage = canonicalizeUrl(homepageUrl);
try {
const response = await fetchTextWithGuards(canonicalHomepage, resolvedConfig);
for (const href of extractAlternateFeedLinks(response.body, response.url || canonicalHomepage)) {
candidates.add(href);
}
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error));
}
const homepage = new URL(canonicalHomepage);
for (const path of COMMON_FEED_PATHS) {
candidates.add(new URL(path, homepage.origin).toString());
}
const feeds: DiscoveredFeed[] = [];
for (const candidate of [...candidates].slice(0, resolvedConfig.maxProbes)) {
const validation = await validateFeed(candidate, resolvedConfig);
if (!validation.ok) {
if (validation.error) {
errors.push(`${candidate}: ${validation.error}`);
}
continue;
}
feeds.push({
title: validation.title ?? candidate,
description: validation.description,
homepageUrl: validation.homepageUrl ?? canonicalHomepage,
feedUrl: candidate,
canonicalFeedUrl: validation.canonicalFeedUrl,
kind: validation.kind,
provider: "web-feed-probe",
language: validation.language,
imageUrl: validation.imageUrl,
lastPublishedAt: validation.lastPublishedAt,
score: 0,
confidence: 0.8,
tags: [],
sampleItems: validation.sampleItems,
isValid: true,
validationScore: 1
});
}
return {
homepageUrl: canonicalHomepage,
feeds,
errors
};
}
export function extractAlternateFeedLinks(html: string, baseUrl: string) {
const $ = cheerio.load(html);
const links: string[] = [];
$("link[rel~='alternate']").each((_, element) => {
const type = String($(element).attr("type") ?? "");
const href = $(element).attr("href");
if (href && FEED_MIME_PATTERN.test(type)) {
links.push(canonicalizeUrl(href, baseUrl));
}
});
return [...new Set(links)];
}

View file

@ -0,0 +1,45 @@
import type { FeedDiscoveryConfig, FeedDiscoveryProvider, FeedProviderManifest } from "../types.js";
import { ITunesPodcastProvider } from "./itunes-podcast.js";
import { ManualCuratedProvider } from "./manual-curated.js";
import { OpmlDirectoryProvider } from "./opml-directory.js";
import { PodcastIndexProvider } from "./podcastindex.js";
import { WebCandidateFeedProbeProvider } from "./web-feed-probe.js";
export function createDefaultFeedProviders(config: FeedDiscoveryConfig): FeedDiscoveryProvider[] {
return [
new ManualCuratedProvider(),
new OpmlDirectoryProvider(config.opmlPaths),
new WebCandidateFeedProbeProvider(config),
new ITunesPodcastProvider(config),
new PodcastIndexProvider(config)
];
}
export function providerManifests(config: FeedDiscoveryConfig): FeedProviderManifest[] {
return createDefaultFeedProviders(config).map((provider) => ({
id: provider.id,
name: provider.name,
type: provider.id.includes("podcast") || provider.id.includes("itunes") ? "podcast" : "all",
requiresApiKey: provider.requiresApiKey,
enabledByDefault: provider.enabledByDefault && (!provider.requiresApiKey || hasProviderKey(provider.id, config)),
description: describeProvider(provider.id)
}));
}
function hasProviderKey(id: string, config: FeedDiscoveryConfig) {
if (id === "podcastindex") {
return Boolean(config.podcastIndexApiKey && config.podcastIndexApiSecret);
}
return true;
}
function describeProvider(id: string) {
const descriptions: Record<string, string> = {
"manual-curated": "Searches locally curated high-trust feed sources.",
"opml-directory": "Searches configured local OPML files.",
"web-feed-probe": "Probes configured candidate homepages and direct URL queries for feeds.",
"itunes-podcast": "Searches the public iTunes podcast directory.",
podcastindex: "Searches PodcastIndex when API credentials are configured."
};
return descriptions[id] ?? "Feed discovery provider.";
}

View file

@ -0,0 +1,64 @@
import type { DiscoveredFeed, FeedDiscoveryConfig, FeedDiscoveryProvider, FeedDiscoveryQuery } from "../types.js";
interface ITunesPodcast {
collectionName?: string;
artistName?: string;
feedUrl?: string;
collectionViewUrl?: string;
artworkUrl600?: string;
primaryGenreName?: string;
releaseDate?: string;
}
export class ITunesPodcastProvider implements FeedDiscoveryProvider {
id = "itunes-podcast";
name = "iTunes Podcast Search";
enabledByDefault = true;
requiresApiKey = false;
constructor(private readonly config: Pick<FeedDiscoveryConfig, "requestTimeoutMs" | "userAgent">) {}
async search(query: FeedDiscoveryQuery) {
if (query.type && query.type !== "all" && query.type !== "podcast") {
return [];
}
const url = new URL("https://itunes.apple.com/search");
url.searchParams.set("term", query.q);
url.searchParams.set("entity", "podcast");
url.searchParams.set("limit", String(Math.min(query.limit ?? 25, 50)));
if (query.locale) {
url.searchParams.set("country", query.locale.toUpperCase());
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: { "user-agent": this.config.userAgent }
});
if (!response.ok) {
throw new Error(`iTunes HTTP ${response.status}`);
}
const body = await response.json() as { results?: ITunesPodcast[] };
return (body.results ?? [])
.filter((item) => item.feedUrl)
.map((item): DiscoveredFeed => ({
title: item.collectionName ?? item.feedUrl ?? "Untitled podcast",
description: item.artistName,
homepageUrl: item.collectionViewUrl,
feedUrl: item.feedUrl as string,
kind: "podcast",
provider: this.id,
imageUrl: item.artworkUrl600,
lastPublishedAt: item.releaseDate,
score: 0,
confidence: 0.85,
tags: [item.primaryGenreName, "podcast"].filter((tag): tag is string => Boolean(tag))
}));
} finally {
clearTimeout(timeout);
}
}
}

View file

@ -0,0 +1,59 @@
import type { DiscoveredFeed, FeedDiscoveryProvider, FeedDiscoveryQuery } from "../types.js";
const CURATED_FEEDS: DiscoveredFeed[] = [
{
title: "Indie Hackers",
description: "Stories and discussions from founders building profitable internet businesses.",
homepageUrl: "https://www.indiehackers.com",
feedUrl: "https://www.indiehackers.com/feed",
kind: "blog",
provider: "manual-curated",
score: 0,
confidence: 0.8,
tags: ["indie", "startup", "microsaas", "saas"]
},
{
title: "Hacker News",
description: "Technology, startup, software, and open-source discussion.",
homepageUrl: "https://news.ycombinator.com",
feedUrl: "https://news.ycombinator.com/rss",
kind: "news",
provider: "manual-curated",
score: 0,
confidence: 0.75,
tags: ["startups", "software", "open-source", "technology"]
},
{
title: "GitHub Blog",
description: "GitHub product, open-source, and developer ecosystem news.",
homepageUrl: "https://github.blog",
feedUrl: "https://github.blog/feed/",
kind: "github",
provider: "manual-curated",
score: 0,
confidence: 0.7,
tags: ["github", "open-source", "software", "developers"]
}
];
export class ManualCuratedProvider implements FeedDiscoveryProvider {
id = "manual-curated";
name = "Manual Curated";
enabledByDefault = true;
requiresApiKey = false;
async search(query: FeedDiscoveryQuery) {
const terms = normalize(query.q);
return CURATED_FEEDS.filter((feed) => {
if (query.type && query.type !== "all" && feed.kind !== query.type) {
return false;
}
const haystack = normalize([feed.title, feed.description, feed.homepageUrl, feed.feedUrl, feed.tags.join(" ")].join(" "));
return terms.length === 0 || terms.some((term) => haystack.includes(term));
}).map((feed) => ({ ...feed, provider: this.id }));
}
}
function normalize(value: string) {
return value.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
}

View file

@ -0,0 +1,92 @@
import { readFile } from "node:fs/promises";
import { XMLParser } from "fast-xml-parser";
import type { DiscoveredFeed, FeedDiscoveryProvider, FeedDiscoveryQuery } from "../types.js";
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "",
textNodeName: "text"
});
export class OpmlDirectoryProvider implements FeedDiscoveryProvider {
id = "opml-directory";
name = "OPML Directory";
enabledByDefault = true;
requiresApiKey = false;
constructor(private readonly paths: string[] = []) {}
async search(query: FeedDiscoveryQuery) {
const feeds: DiscoveredFeed[] = [];
for (const path of this.paths) {
const content = await readFile(path, "utf8");
const parsed = parser.parse(content) as unknown;
for (const outline of collectOutlines(parsed)) {
const feedUrl = stringValue(outline.xmlUrl);
if (!feedUrl) {
continue;
}
const title = stringValue(outline.title) || stringValue(outline.text) || feedUrl;
const tags = [stringValue(outline.category)].filter((tag): tag is string => Boolean(tag));
const feed: DiscoveredFeed = {
title,
description: stringValue(outline.description),
homepageUrl: stringValue(outline.htmlUrl),
feedUrl,
kind: inferKind(tags.join(" "), query.type),
provider: this.id,
score: 0,
confidence: 0.7,
tags
};
if (matches(feed, query)) {
feeds.push(feed);
}
}
}
return feeds;
}
}
function collectOutlines(value: unknown): Array<Record<string, unknown>> {
if (!isRecord(value)) {
return [];
}
const current = "xmlUrl" in value ? [value] : [];
return [
...current,
...Object.values(value).flatMap((entry) => {
if (Array.isArray(entry)) {
return entry.flatMap(collectOutlines);
}
return collectOutlines(entry);
})
];
}
function matches(feed: DiscoveredFeed, query: FeedDiscoveryQuery) {
if (query.type && query.type !== "all" && feed.kind !== query.type) {
return false;
}
const terms = query.q.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
const haystack = [feed.title, feed.description, feed.homepageUrl, feed.feedUrl, feed.tags.join(" ")].join(" ").toLowerCase();
return terms.length === 0 || terms.some((term) => haystack.includes(term));
}
function inferKind(tags: string, requested?: FeedDiscoveryQuery["type"]) {
if (requested && requested !== "all") {
return requested;
}
if (tags.includes("podcast")) {
return "podcast";
}
return "opml";
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -0,0 +1,76 @@
import { createHash } from "node:crypto";
import type { DiscoveredFeed, FeedDiscoveryConfig, FeedDiscoveryProvider, FeedDiscoveryQuery } from "../types.js";
interface PodcastIndexFeed {
title?: string;
description?: string;
url?: string;
link?: string;
image?: string;
language?: string;
newestItemPublishTime?: number;
categories?: Record<string, string>;
}
export class PodcastIndexProvider implements FeedDiscoveryProvider {
id = "podcastindex";
name = "PodcastIndex";
enabledByDefault = false;
requiresApiKey = true;
constructor(private readonly config: FeedDiscoveryConfig) {}
async search(query: FeedDiscoveryQuery) {
if (!this.config.podcastIndexApiKey || !this.config.podcastIndexApiSecret) {
return [];
}
if (query.type && query.type !== "all" && query.type !== "podcast") {
return [];
}
const authDate = Math.floor(Date.now() / 1000).toString();
const auth = createHash("sha1")
.update(this.config.podcastIndexApiKey + this.config.podcastIndexApiSecret + authDate)
.digest("hex");
const url = new URL("https://api.podcastindex.org/api/1.0/search/byterm");
url.searchParams.set("q", query.q);
url.searchParams.set("max", String(Math.min(query.limit ?? 25, 100)));
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
"user-agent": this.config.userAgent,
"X-Auth-Date": authDate,
"X-Auth-Key": this.config.podcastIndexApiKey,
Authorization: auth
}
});
if (!response.ok) {
throw new Error(`PodcastIndex HTTP ${response.status}`);
}
const body = await response.json() as { feeds?: PodcastIndexFeed[] };
return (body.feeds ?? [])
.filter((feed) => feed.url)
.map((feed): DiscoveredFeed => ({
title: feed.title ?? feed.url ?? "Untitled podcast",
description: feed.description,
homepageUrl: feed.link,
feedUrl: feed.url as string,
kind: "podcast",
provider: this.id,
language: feed.language,
imageUrl: feed.image,
lastPublishedAt: feed.newestItemPublishTime ? new Date(feed.newestItemPublishTime * 1000).toISOString() : undefined,
score: 0,
confidence: 0.9,
tags: [...Object.values(feed.categories ?? {}), "podcast"]
}));
} finally {
clearTimeout(timeout);
}
}
}

View file

@ -0,0 +1,42 @@
import type { DiscoveredFeed, FeedDiscoveryConfig, FeedDiscoveryProvider, FeedDiscoveryQuery } from "../types.js";
import { probeSite } from "../probe-site.js";
export class WebCandidateFeedProbeProvider implements FeedDiscoveryProvider {
id = "web-feed-probe";
name = "Web Candidate Feed Probe";
enabledByDefault = true;
requiresApiKey = false;
constructor(private readonly config: FeedDiscoveryConfig) {}
async search(query: FeedDiscoveryQuery) {
const candidates = candidateUrls(query, this.config.candidateUrls).slice(0, this.config.maxProbes);
const feeds: DiscoveredFeed[] = [];
for (const candidate of candidates) {
const result = await probeSite(candidate, this.config);
feeds.push(...result.feeds.map((feed) => ({ ...feed, provider: this.id })));
}
return feeds.filter((feed) => !query.type || query.type === "all" || feed.kind === query.type);
}
}
function candidateUrls(query: FeedDiscoveryQuery, configured: string[]) {
const candidates = new Set<string>();
if (/^https?:\/\//i.test(query.q)) {
candidates.add(query.q);
}
for (const entry of configured) {
const [url, ...keywords] = entry.split("|").map((part) => part.trim());
if (!url) {
continue;
}
if (keywords.length === 0 || keywords.some((keyword) => query.q.toLowerCase().includes(keyword.toLowerCase()))) {
candidates.add(url);
}
}
return [...candidates];
}

View file

@ -0,0 +1,95 @@
import type { DiscoveredFeed, FeedDiscoveryQuery } from "./types.js";
const PROVIDER_SCORES: Record<string, number> = {
"manual-curated": 1,
podcastindex: 0.9,
"itunes-podcast": 0.85,
"opml-directory": 0.75,
"web-feed-probe": 0.7,
rsshub: 0.7,
reddit: 0.65,
github: 0.65,
youtube: 0.65,
unknown: 0.3
};
export function scoreFeed(feed: DiscoveredFeed, query: FeedDiscoveryQuery): DiscoveredFeed {
const keywordScore = feed.keywordScore ?? calculateKeywordScore(feed, query.q);
const freshnessScore = feed.freshnessScore ?? calculateFreshnessScore(feed.lastPublishedAt);
const providerScore = feed.providerScore ?? PROVIDER_SCORES[feed.provider] ?? PROVIDER_SCORES.unknown;
const validationScore = feed.validationScore ?? (feed.isValid === false ? 0.2 : 1);
const score = keywordScore * 0.4 + freshnessScore * 0.25 + providerScore * 0.2 + validationScore * 0.15;
return {
...feed,
score: round(score),
confidence: round(Math.max(feed.confidence, score * validationScore)),
freshnessScore: round(freshnessScore),
keywordScore: round(keywordScore),
providerScore: round(providerScore),
validationScore: round(validationScore)
};
}
export function calculateKeywordScore(feed: DiscoveredFeed, query: string) {
const terms = normalizeTerms(query);
if (terms.length === 0) {
return 0.1;
}
const weightedText = [
[feed.title, 0.35],
[feed.description, 0.2],
[feed.homepageUrl, 0.1],
[feed.feedUrl, 0.05],
[feed.tags.join(" "), 0.15],
[feed.sampleItems?.map((item) => item.title).join(" "), 0.15]
] as const;
let score = 0;
for (const [value, weight] of weightedText) {
const text = String(value ?? "").toLowerCase();
if (!text) {
continue;
}
const matches = terms.filter((term) => text.includes(term)).length;
score += weight * (matches / terms.length);
}
return Math.min(1, Math.max(0.05, score));
}
export function calculateFreshnessScore(lastPublishedAt?: string) {
if (!lastPublishedAt) {
return 0.1;
}
const ageDays = (Date.now() - Date.parse(lastPublishedAt)) / 86_400_000;
if (!Number.isFinite(ageDays) || ageDays < 0) {
return 0.1;
}
if (ageDays <= 7) {
return 1;
}
if (ageDays <= 30) {
return 0.8;
}
if (ageDays <= 90) {
return 0.6;
}
if (ageDays <= 365) {
return 0.3;
}
return 0.1;
}
function normalizeTerms(query: string) {
return query
.toLowerCase()
.split(/[^a-z0-9]+/)
.map((term) => term.trim())
.filter(Boolean);
}
function round(value: number) {
return Math.round(value * 1000) / 1000;
}

View file

@ -0,0 +1,113 @@
export type FeedKind =
| "blog"
| "news"
| "podcast"
| "youtube"
| "reddit"
| "github"
| "torrent"
| "opml"
| "rsshub"
| "unknown";
export type FeedOutputFormat = "json" | "opml" | "rss" | "atom" | "json-feed";
export interface FeedDiscoveryQuery {
q: string;
type?: FeedKind | "all";
limit?: number;
locale?: string;
freshnessDays?: number;
includeDeadFeeds?: boolean;
includeUnvalidated?: boolean;
providers?: string[];
}
export interface DiscoveredFeed {
id?: string;
title: string;
description?: string;
homepageUrl?: string;
feedUrl: string;
canonicalFeedUrl?: string;
kind: FeedKind;
provider: string;
language?: string;
imageUrl?: string;
lastPublishedAt?: string;
score: number;
confidence: number;
freshnessScore?: number;
keywordScore?: number;
providerScore?: number;
validationScore?: number;
tags: string[];
sampleItems?: FeedSampleItem[];
isValid?: boolean;
}
export interface FeedSampleItem {
title: string;
url?: string;
publishedAt?: string;
description?: string;
}
export interface FeedDiscoveryProvider {
id: string;
name: string;
enabledByDefault: boolean;
requiresApiKey: boolean;
search(query: FeedDiscoveryQuery): Promise<DiscoveredFeed[]>;
}
export interface FeedProviderManifest {
id: string;
name: string;
type: FeedKind | "all";
requiresApiKey: boolean;
enabledByDefault: boolean;
description: string;
}
export interface FeedDiscoveryResponse {
query: string;
normalizedQuery: string;
count: number;
providerErrors: Array<{ provider: string; error: string }>;
results: DiscoveredFeed[];
}
export interface FeedDiscoveryConfig {
cacheTtlSeconds: number;
maxProviders: number;
maxProbes: number;
requestTimeoutMs: number;
maxBodyBytes: number;
userAgent: string;
opmlPaths: string[];
candidateUrls: string[];
podcastIndexApiKey?: string;
podcastIndexApiSecret?: string;
}
export interface ValidationResult {
ok: boolean;
feedUrl: string;
canonicalFeedUrl?: string;
title?: string;
description?: string;
homepageUrl?: string;
kind: FeedKind;
language?: string;
imageUrl?: string;
lastPublishedAt?: string;
sampleItems: FeedSampleItem[];
error?: string;
}
export interface ProbeResult {
homepageUrl: string;
feeds: DiscoveredFeed[];
errors: string[];
}

View file

@ -0,0 +1,89 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const BLOCKED_HOSTS = new Set(["localhost", "metadata.google.internal"]);
export async function assertSafeHttpUrl(input: string) {
const url = new URL(input);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Unsupported URL protocol: ${url.protocol}`);
}
const hostname = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
if (BLOCKED_HOSTS.has(hostname) || hostname.endsWith(".localhost")) {
throw new Error(`Blocked internal hostname: ${hostname}`);
}
if (isBlockedIp(hostname)) {
throw new Error(`Blocked internal IP address: ${hostname}`);
}
const addresses = await lookup(hostname, { all: true, verbatim: true });
for (const address of addresses) {
if (isBlockedIp(address.address)) {
throw new Error(`Blocked internal resolved address: ${address.address}`);
}
}
return url;
}
export function canonicalizeUrl(input: string, base?: string) {
const url = new URL(input, base);
url.hash = "";
for (const param of [...url.searchParams.keys()]) {
if (/^(utm_|fbclid$|gclid$|mc_cid$|mc_eid$)/i.test(param)) {
url.searchParams.delete(param);
}
}
if ((url.protocol === "https:" && url.port === "443") || (url.protocol === "http:" && url.port === "80")) {
url.port = "";
}
if (url.pathname !== "/" && url.pathname.endsWith("/")) {
url.pathname = url.pathname.slice(0, -1);
}
return url.toString();
}
function isBlockedIp(value: string) {
const kind = isIP(value);
if (kind === 4) {
const parts = value.split(".").map((part) => Number(part));
const [a, b] = parts;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127)
);
}
if (kind === 6) {
const normalized = value.toLowerCase();
const mappedDottedIpv4 = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(normalized);
if (mappedDottedIpv4) {
return isBlockedIp(mappedDottedIpv4[1]);
}
const mappedHexIpv4 = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(normalized);
if (mappedHexIpv4) {
const high = Number.parseInt(mappedHexIpv4[1], 16);
const low = Number.parseInt(mappedHexIpv4[2], 16);
return isBlockedIp(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
}
return (
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe80")
);
}
return false;
}

View file

@ -0,0 +1,27 @@
import { readFeedDiscoveryConfig } from "./config.js";
import { parseFeedDocument } from "./feed-parsing.js";
import { fetchTextWithGuards } from "./http.js";
import type { FeedDiscoveryConfig, ValidationResult } from "./types.js";
import { canonicalizeUrl } from "./url-safety.js";
export async function validateFeed(feedUrl: string, config: Partial<FeedDiscoveryConfig> = {}): Promise<ValidationResult> {
const resolvedConfig = { ...readFeedDiscoveryConfig(), ...config };
try {
const response = await fetchTextWithGuards(feedUrl, resolvedConfig);
const canonicalFeedUrl = canonicalizeUrl(response.url || feedUrl);
const parsed = parseFeedDocument(canonicalFeedUrl, response.body, response.contentType);
return {
...parsed,
feedUrl,
canonicalFeedUrl
};
} catch (error) {
return {
ok: false,
feedUrl,
kind: "unknown",
sampleItems: [],
error: error instanceof Error ? error.message : String(error)
};
}
}