From 53aaf5055749c070ef9268311011a9a45d07d14d Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:13:44 +0100 Subject: [PATCH] fix: improve fediverse detection and feed discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetcher.js: try HTML discovery before falling back to common feed paths (/feed, /rss.xml, etc.), fixing subscriptions to sites like Substack, econsoc.mpifg.de, and others whose feed URL is advertised via a link element but not at a predictable path - reader.js: extend detectProtocol to recognise more fediverse domains (troet., social., hachyderm., infosec.exchange, chaos.social) - reader.js: don't auto-check Mastodon syndication target for likes and reposts — those are handled natively by the AP endpoint; use service-name-aware target matching that works for any configured Mastodon instance even if its domain isn't in the hardcoded list Co-Authored-By: Claude Sonnet 4.6 --- lib/controllers/reader.js | 32 ++++++++++++++++++++++++++++---- lib/feeds/fetcher.js | 19 +++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index 2da5ded..1f153a2 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -446,8 +446,11 @@ function detectProtocol(url) { if (!url || typeof url !== "string") return "web"; const lower = url.toLowerCase(); if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere"; + // Well-known fediverse software domain patterns if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") || - lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.")) return "fediverse"; + lower.includes("troet.") || lower.includes("social.") || lower.includes("pleroma.") || + lower.includes("misskey.") || lower.includes("pixelfed.") || lower.includes("hachyderm.") || + lower.includes("infosec.exchange") || lower.includes("chaos.social")) return "fediverse"; return "web"; } @@ -510,15 +513,36 @@ export async function compose(request, response) { ? await getSyndicationTargets(application, token) : []; - // Auto-select syndication target based on interaction URL protocol + // Auto-select syndication target based on interaction URL protocol. + // Likes and reposts on fediverse are handled natively by the AP endpoint — + // never auto-check Mastodon for those action types. + const isLikeOrRepost = !!(likeOf || like || repostOf || repost); const interactionUrl = ensureString(replyTo || reply || likeOf || like || repostOf || repost); - if (interactionUrl && syndicationTargets.length > 0) { + if (interactionUrl && syndicationTargets.length > 0 && !isLikeOrRepost) { const protocol = detectProtocol(interactionUrl); + + // Build set of Mastodon instance hostnames from configured targets so we + // can match same-instance URLs even if not in the hardcoded pattern list. + const mastodonHostnames = new Set(); + for (const t of syndicationTargets) { + if (t.service?.name?.toLowerCase() === "mastodon" && t.service?.url) { + try { mastodonHostnames.add(new URL(t.service.url).hostname.toLowerCase()); } catch { /* ignore */ } + } + } + let interactionHostname = ""; + try { interactionHostname = new URL(interactionUrl).hostname.toLowerCase(); } catch { /* ignore */ } + for (const target of syndicationTargets) { const targetId = (target.uid || target.name || "").toLowerCase(); + // Identify a Mastodon target by service name (reliable) or legacy uid/name patterns + const isMastodonTarget = + target.service?.name?.toLowerCase() === "mastodon" || + targetId.includes("mastodon") || + targetId.includes("mstdn"); + if (protocol === "atmosphere" && (targetId.includes("bluesky") || targetId.includes("bsky"))) { target.checked = true; - } else if (protocol === "fediverse" && (targetId.includes("mastodon") || targetId.includes("mstdn"))) { + } else if (isMastodonTarget && (protocol === "fediverse" || mastodonHostnames.has(interactionHostname))) { target.checked = true; } } diff --git a/lib/feeds/fetcher.js b/lib/feeds/fetcher.js index dc8da13..8a66512 100644 --- a/lib/feeds/fetcher.js +++ b/lib/feeds/fetcher.js @@ -164,9 +164,24 @@ export async function fetchAndParseFeed(url, options = {}) { // Check if we got a parseable feed const feedType = detectFeedType(result.content, result.contentType); - // If we got ActivityPub or unknown, try common feed paths + // If we got ActivityPub or unknown, try link-based discovery then common paths if (feedType === "activitypub" || feedType === "unknown") { - const fallbackFeed = await tryCommonFeedPaths(url, options); + // 1. link-based discovery from HTML: parse + let discoveredFeedUrl; + if (result.content) { + const { discoverFeeds } = await import("./hfeed.js"); + const discovered = await discoverFeeds(result.content, url); + const rssOrAtom = discovered.find( + (f) => f.type === "rss" || f.type === "atom" || f.type === "jsonfeed", + ); + if (rssOrAtom) discoveredFeedUrl = rssOrAtom.url; + } + + // 2. Fall back to common feed paths (/feed, /rss.xml, etc.) + const fallbackFeed = discoveredFeedUrl + ? { url: discoveredFeedUrl } + : await tryCommonFeedPaths(url, options); + if (fallbackFeed) { // Fetch and parse the discovered feed const feedResult = await fetchFeed(fallbackFeed.url, options);