diff --git a/plugins/feed-discovery/src/discovery.ts b/plugins/feed-discovery/src/discovery.ts index 19f1e26..17c193f 100644 --- a/plugins/feed-discovery/src/discovery.ts +++ b/plugins/feed-discovery/src/discovery.ts @@ -15,6 +15,7 @@ export async function discoverFeeds(query: FeedDiscoveryQuery, options: { provid q: query.q.trim(), type: query.type ?? "all", limit: clampLimit(query.limit), + freshnessDays: normalizeFreshnessDays(query.freshnessDays), includeUnvalidated: query.includeUnvalidated ?? false, includeDeadFeeds: query.includeDeadFeeds ?? false }; @@ -54,6 +55,7 @@ export async function discoverFeeds(query: FeedDiscoveryQuery, options: { provid 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) + .filter((feed) => isWithinFreshnessWindow(feed.lastPublishedAt, resolvedQuery.freshnessDays)) .map((feed) => scoreFeed(feed, resolvedQuery)); const results = dedupeFeeds(scored).slice(0, resolvedQuery.limit ?? 25); @@ -111,6 +113,23 @@ function clampLimit(limit: number | undefined) { return Math.min(Math.max(Math.trunc(limit), 1), 100); } +function normalizeFreshnessDays(freshnessDays: number | undefined) { + return typeof freshnessDays === "number" && Number.isFinite(freshnessDays) && freshnessDays > 0 + ? freshnessDays + : undefined; +} + +function isWithinFreshnessWindow(lastPublishedAt: string | undefined, freshnessDays: number | undefined) { + if (freshnessDays === undefined) { + return true; + } + if (!lastPublishedAt) { + return false; + } + const ageMs = Date.now() - Date.parse(lastPublishedAt); + return Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= freshnessDays * 86_400_000; +} + function safeCanonical(url: string) { try { return canonicalizeUrl(url); diff --git a/plugins/feed-discovery/src/feed-discovery.test.ts b/plugins/feed-discovery/src/feed-discovery.test.ts index f2c4383..3fe2253 100644 --- a/plugins/feed-discovery/src/feed-discovery.test.ts +++ b/plugins/feed-discovery/src/feed-discovery.test.ts @@ -100,4 +100,29 @@ describe("discovery orchestration", () => { expect(response.providerErrors).toEqual([{ provider: "broken-provider", error: "provider unavailable" }]); expect(response.results).toHaveLength(1); }); + + it("enforces the requested freshness window", async () => { + const provider: FeedDiscoveryProvider = { + id: "freshness-provider", + name: "Freshness", + enabledByDefault: true, + requiresApiKey: false, + async search() { + return [ + { ...baseFeed, feedUrl: "https://example.com/fresh.xml", lastPublishedAt: new Date(Date.now() - 5 * 86_400_000).toISOString() }, + { ...baseFeed, feedUrl: "https://example.com/stale.xml", lastPublishedAt: new Date(Date.now() - 60 * 86_400_000).toISOString() }, + { ...baseFeed, feedUrl: "https://example.com/unknown.xml", lastPublishedAt: undefined }, + { ...baseFeed, feedUrl: "https://example.com/invalid.xml", lastPublishedAt: "not-a-date" }, + { ...baseFeed, feedUrl: "https://example.com/future.xml", lastPublishedAt: new Date(Date.now() + 86_400_000).toISOString() } + ]; + } + }; + + const response = await discoverFeeds( + { q: "microsaas", freshnessDays: 30, includeUnvalidated: true }, + { providers: [provider] } + ); + + expect(response.results.map((feed) => feed.feedUrl)).toEqual(["https://example.com/fresh.xml"]); + }); });