mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-15 07:17:30 +00:00
Add feed discovery plugin
This commit is contained in:
parent
e47616bf9d
commit
5cfeea6b57
37 changed files with 2432 additions and 5 deletions
34
plugins/feed-discovery/src/config.ts
Normal file
34
plugins/feed-discovery/src/config.ts
Normal 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);
|
||||
}
|
||||
62
plugins/feed-discovery/src/db/schema.sql
Normal file
62
plugins/feed-discovery/src/db/schema.sql
Normal 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()
|
||||
);
|
||||
38
plugins/feed-discovery/src/dedupe.ts
Normal file
38
plugins/feed-discovery/src/dedupe.ts
Normal 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
|
||||
};
|
||||
}
|
||||
120
plugins/feed-discovery/src/discovery.ts
Normal file
120
plugins/feed-discovery/src/discovery.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
102
plugins/feed-discovery/src/feed-discovery.test.ts
Normal file
102
plugins/feed-discovery/src/feed-discovery.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
204
plugins/feed-discovery/src/feed-parsing.ts
Normal file
204
plugins/feed-discovery/src/feed-parsing.ts
Normal 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);
|
||||
}
|
||||
80
plugins/feed-discovery/src/http.ts
Normal file
80
plugins/feed-discovery/src/http.ts
Normal 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;
|
||||
}
|
||||
47
plugins/feed-discovery/src/index.ts
Normal file
47
plugins/feed-discovery/src/index.ts
Normal 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";
|
||||
27
plugins/feed-discovery/src/manifest.ts
Normal file
27
plugins/feed-discovery/src/manifest.ts
Normal 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"
|
||||
]
|
||||
};
|
||||
26
plugins/feed-discovery/src/output/atom.ts
Normal file
26
plugins/feed-discovery/src/output/atom.ts
Normal 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>`;
|
||||
}
|
||||
23
plugins/feed-discovery/src/output/index.ts
Normal file
23
plugins/feed-discovery/src/output/index.ts
Normal 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 };
|
||||
22
plugins/feed-discovery/src/output/json-feed.ts
Normal file
22
plugins/feed-discovery/src/output/json-feed.ts
Normal 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
|
||||
);
|
||||
}
|
||||
27
plugins/feed-discovery/src/output/opml.ts
Normal file
27
plugins/feed-discovery/src/output/opml.ts
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
25
plugins/feed-discovery/src/output/rss.ts
Normal file
25
plugins/feed-discovery/src/output/rss.ts
Normal 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 });
|
||||
}
|
||||
93
plugins/feed-discovery/src/probe-site.ts
Normal file
93
plugins/feed-discovery/src/probe-site.ts
Normal 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)];
|
||||
}
|
||||
45
plugins/feed-discovery/src/providers/index.ts
Normal file
45
plugins/feed-discovery/src/providers/index.ts
Normal 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.";
|
||||
}
|
||||
64
plugins/feed-discovery/src/providers/itunes-podcast.ts
Normal file
64
plugins/feed-discovery/src/providers/itunes-podcast.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
plugins/feed-discovery/src/providers/manual-curated.ts
Normal file
59
plugins/feed-discovery/src/providers/manual-curated.ts
Normal 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);
|
||||
}
|
||||
92
plugins/feed-discovery/src/providers/opml-directory.ts
Normal file
92
plugins/feed-discovery/src/providers/opml-directory.ts
Normal 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);
|
||||
}
|
||||
76
plugins/feed-discovery/src/providers/podcastindex.ts
Normal file
76
plugins/feed-discovery/src/providers/podcastindex.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
plugins/feed-discovery/src/providers/web-feed-probe.ts
Normal file
42
plugins/feed-discovery/src/providers/web-feed-probe.ts
Normal 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];
|
||||
}
|
||||
95
plugins/feed-discovery/src/scoring.ts
Normal file
95
plugins/feed-discovery/src/scoring.ts
Normal 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;
|
||||
}
|
||||
113
plugins/feed-discovery/src/types.ts
Normal file
113
plugins/feed-discovery/src/types.ts
Normal 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[];
|
||||
}
|
||||
89
plugins/feed-discovery/src/url-safety.ts
Normal file
89
plugins/feed-discovery/src/url-safety.ts
Normal 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;
|
||||
}
|
||||
27
plugins/feed-discovery/src/validate-feed.ts
Normal file
27
plugins/feed-discovery/src/validate-feed.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue