mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 14:57:28 +00:00
Add feed discovery plugin
This commit is contained in:
parent
e47616bf9d
commit
5cfeea6b57
37 changed files with 2432 additions and 5 deletions
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue