From d6bda7ce2fc3d735af3403ad5ffadb30beee5783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bojan=20Stefanovi=C4=87?= <5675392+bojanstef@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:06:54 -0400 Subject: [PATCH] Prefer a page-specific feed over a site-wide feed in Find Feed (#5299) findFeedsInHTMLPage returned early with the feed whenever one existed, discarding every body-discovered feed. For a /blog request on a site whose advertises a site-wide feed (e.g. relay.fm's all-network feed) while the page links its own feed in the body ("Subscribe via RSS"), the page-specific feed was dropped before bestFeed could consider it. Before falling back to the feed, validate any body-linked candidate whose URL is on the same host and nested under the requested page's path, and prefer it. Host comparison is www- and case-insensitive (via localeForLowercasing); root requests (no path) keep the existing -feed behavior, so whole-site Find Feed is unchanged. A unit test covers the path/host matching. Diagnosed by tracing the discovery path at runtime: both feeds were discovered, but the body feed was discarded by the didFindFeedInHTMLHead early return before bestFeed ran. Written with LLM assistance and reviewed to meet the project's quality bar; clean module build with no new warnings, swiftlint --strict clean, tests pass. Generated-by: claude-opus-4-8 Co-Authored-By: Claude Opus 4.8 --- .../Sources/FeedFinder/FeedFinder.swift | 43 +++++++++++++++++++ .../FeedFinderTests/FeedFinderTests.swift | 31 +++++++++++++ 2 files changed, 74 insertions(+) diff --git a/Modules/FeedFinder/Sources/FeedFinder/FeedFinder.swift b/Modules/FeedFinder/Sources/FeedFinder/FeedFinder.swift index 74731d2b72..bc02dd7fe1 100644 --- a/Modules/FeedFinder/Sources/FeedFinder/FeedFinder.swift +++ b/Modules/FeedFinder/Sources/FeedFinder/FeedFinder.swift @@ -144,6 +144,32 @@ public final class FeedFinder { } } +extension FeedFinder { + + /// True when `feedURLString` is on the same host as `pageURLString` and its + /// path equals or is nested under the page's path. The page must have a + /// non-root path — a root request (e.g. example.com/) gets no on-path + /// preference, preserving the existing -feed behavior for whole sites. + static func feedURLString(_ feedURLString: String, isUnderRequestedPageURLString pageURLString: String) -> Bool { + guard let feedURL = URL(string: feedURLString), let pageURL = URL(string: pageURLString), + let feedHost = feedURL.host(), let pageHost = pageURL.host() else { + return false + } + func normalizedHost(_ host: String) -> String { + let lowered = host.lowercased(with: localeForLowercasing) + return lowered.hasPrefix("www.") ? String(lowered.dropFirst("www.".count)) : lowered + } + guard normalizedHost(feedHost) == normalizedHost(pageHost) else { + return false + } + let pagePath = pageURL.path.hasSuffix("/") ? String(pageURL.path.dropLast()) : pageURL.path + guard pagePath.count > 1 else { + return false + } + return feedURL.path == pagePath || feedURL.path.hasPrefix(pagePath + "/") + } +} + private extension FeedFinder { static func addFeedSpecifier(_ feedSpecifier: FeedSpecifier, feedSpecifiers: inout [String: FeedSpecifier]) { @@ -180,6 +206,23 @@ private extension FeedFinder { } } + // A body-linked feed whose URL lives *under* the requested page's path + // (e.g. /blog/feed for a /blog request) is more specific than a + // site-wide feed declared in (e.g. a network master feed). Prefer + // it: validate the on-path body candidates and, if any are real feeds, + // return those instead of the feed. Without this, any feed + // unconditionally suppressed every body feed, so a page-specific feed was + // discarded before it could be considered. (#5299) + let onPathToDownload = feedSpecifiersToDownload.filter { + Self.feedURLString($0.urlString, isUnderRequestedPageURLString: urlString) + } + if !onPathToDownload.isEmpty { + let onPathFeeds = await downloadFeedSpecifiers(onPathToDownload, feedSpecifiers: [:]) + if !onPathFeeds.isEmpty { + return (onPathFeeds, .candidates) + } + } + if didFindFeedInHTMLHead { return (Set(feedSpecifiers.values), .htmlHead) } else if feedSpecifiersToDownload.isEmpty { diff --git a/Modules/FeedFinder/Tests/FeedFinderTests/FeedFinderTests.swift b/Modules/FeedFinder/Tests/FeedFinderTests/FeedFinderTests.swift index 8d43fc7e87..bc4e826472 100644 --- a/Modules/FeedFinder/Tests/FeedFinderTests/FeedFinderTests.swift +++ b/Modules/FeedFinder/Tests/FeedFinderTests/FeedFinderTests.swift @@ -15,4 +15,35 @@ final class FeedFinderTests: XCTestCase { let feedFinder = FeedFinder() XCTAssertNotNil(feedFinder) } + + // Covers #5299: a page-specific feed linked in the body (e.g. /blog/feed) + // must be recognized as "under" the requested page path so it can be + // preferred over a site-wide feed. + func testFeedURLIsUnderRequestedPagePath() { + func isUnder(_ feed: String, _ page: String) -> Bool { + FeedFinder.feedURLString(feed, isUnderRequestedPageURLString: page) + } + + // The bug case: /blog/feed is under the requested /blog page… + XCTAssertTrue(isUnder("https://www.relay.fm/blog/feed", "https://www.relay.fm/blog")) + // …while the site-wide feed at a different path is not on-path. + XCTAssertFalse(isUnder("http://relay.fm/master/feed", "https://www.relay.fm/blog")) + + // Host comparison is www- and case-insensitive. + XCTAssertTrue(isUnder("https://relay.fm/blog/feed", "https://www.relay.fm/blog")) + XCTAssertTrue(isUnder("https://WWW.Relay.FM/blog/feed", "https://www.relay.fm/blog")) + + // A different host is never on-path. + XCTAssertFalse(isUnder("https://example.com/blog/feed", "https://www.relay.fm/blog")) + + // A root request gets no on-path preference (whole-site Find Feed unchanged). + XCTAssertFalse(isUnder("https://relay.fm/feed", "https://relay.fm/")) + XCTAssertFalse(isUnder("https://relay.fm/feed", "https://relay.fm")) + + // A sibling that merely shares a path prefix is not nested under it. + XCTAssertFalse(isUnder("https://relay.fm/blogger/feed", "https://relay.fm/blog")) + + // An exact path match counts as on-path. + XCTAssertTrue(isUnder("https://relay.fm/blog", "https://relay.fm/blog")) + } }