package org.terst.feedreader.data import java.net.URI /** * Finds the feed a web page advertises. * * Reported live: "Can't subscribe to https://heathercoxrichardson.substack.com/". Adding a feed * fetched whatever URL it was given and handed it straight to [FeedParser], so a site's home * page -- the only URL a reader can actually see and copy -- failed on the XML parse with a * message about an unrecognised root element. * * Nothing exotic is needed to fix it. Sites have advertised their feeds in the document head * since RSS autodiscovery was agreed in 2002, and that Substack page does: * * ``` * * ``` * * Only what the page itself declares is used. Guessing paths (`/feed`, `/rss`, `/index.xml`) * would add a burst of 404s on every failed add and would eventually subscribe somebody to the * wrong thing; a site that declares nothing is reported as having no feed, which is the truth. */ object FeedDiscovery { /** Feed content types, as `type=` gives them. */ private val FEED_TYPES = listOf("application/rss+xml", "application/atom+xml", "application/rdf+xml", "text/xml") private val LINK_TAG = Regex("]*>", RegexOption.IGNORE_CASE) /** `name="value"`, `name='value'` or `name=value`, in any order within the tag. */ private val ATTRIBUTE = Regex("""(?[a-zA-Z-]+)\s*=\s*("(?[^"]*)"|'(?[^']*)'|(?[^\s">]+))""") /** * The feed URL [html] advertises, absolute, or null when it advertises none. * * [pageUrl] is the address the HTML came from, which a relative href resolves against -- * against the page, not the site root, since a feed link on `/blog/` routinely says * `feed.xml` and means `/blog/feed.xml`. */ fun findFeedUrl(html: String, pageUrl: String): String? { // Already a feed: there is nothing to discover, and scanning XML for tags would // only find Atom's own self-links and send the caller round in a circle. if (html.trimStart().startsWith(" attrs["rel"]?.contains("alternate", ignoreCase = true) == true && attrs["type"]?.lowercase()?.trim() in FEED_TYPES } ?.get("href") ?.takeIf { it.isNotBlank() } ?.let { resolve(it, pageUrl) } } private fun attributesOf(tag: String): Map = ATTRIBUTE.findAll(tag).associate { match -> // Named rather than numbered: which of the three quoting alternatives matched is // the whole question here, and "groupValues[4]" does not say that to a reader. val value = match.groups["dq"]?.value ?: match.groups["sq"]?.value ?: match.groups["bare"]?.value.orEmpty() match.groups["name"]!!.value.lowercase() to value } /** * [href] as an absolute https URL. * * The https upgrade is the same rule the add-feed screen already applies to what the user * types, for the same reason: Android blocks cleartext, so an http feed would fail every * fetch forever. A site that advertises an http feed almost always serves it over https too. */ private fun resolve(href: String, pageUrl: String): String? = try { val absolute = URI(pageUrl).resolve(href.trim()).toString() if (absolute.startsWith("http://", ignoreCase = true)) { "https://" + absolute.removePrefix("http://").removePrefix("HTTP://") } else { absolute } } catch (ignored: IllegalArgumentException) { // A malformed href is a page problem, not a crash: report no feed and let the caller // say so, the same as a page that advertises none. Nothing is lost by not reporting // it -- "no feed found at that address" is exactly as true and as useful either way. null } }