From 22bfe862646495d2531d3e054aa5b5bb64701f33 Mon Sep 17 00:00:00 2001 From: heberjeur Date: Sun, 23 Aug 2026 17:12:41 +0100 Subject: [PATCH] fix(rss): bypass WAF bot blocking with browser User-Agent and fix search feed hang (#1164) --- .../domain/service/AbstractRssRepository.kt | 2 +- .../infrastructure/di/OkHttpClientModule.kt | 364 +++++++++--------- .../infrastructure/rss/BestIconFinder.kt | 10 +- .../reader/infrastructure/rss/RssHelper.kt | 33 +- .../feeds/subscribe/SubscribeViewModel.kt | 2 +- .../di/OkHttpClientModuleTest.kt | 90 +++++ .../infrastructure/rss/RssHelperTest.kt | 58 +++ 7 files changed, 364 insertions(+), 195 deletions(-) create mode 100644 app/src/test/java/me/ash/reader/infrastructure/di/OkHttpClientModuleTest.kt diff --git a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt index 32f66c7ba..d92e5ab69 100644 --- a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt +++ b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt @@ -63,7 +63,7 @@ abstract class AbstractRssRepository( val feed = Feed( id = accountId.spacerDollar(UUID.randomUUID().toString()), - name = searchedFeed.title.decodeHTML()!!, + name = searchedFeed.title?.decodeHTML() ?: feedLink, url = feedLink, groupId = groupId, accountId = accountId, diff --git a/app/src/main/java/me/ash/reader/infrastructure/di/OkHttpClientModule.kt b/app/src/main/java/me/ash/reader/infrastructure/di/OkHttpClientModule.kt index 45edfa5f0..2742e1daa 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/di/OkHttpClientModule.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/di/OkHttpClientModule.kt @@ -1,177 +1,187 @@ -/* - * Feeder: Android RSS reader app - * https://gitlab.com/spacecowboy/Feeder - * - * Copyright (C) 2022 Jonas Kalderstam - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package me.ash.reader.infrastructure.di - -import android.annotation.SuppressLint -import android.content.Context -import android.security.KeyChain -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import me.ash.reader.BuildConfig -import okhttp3.Cache -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Response -import okhttp3.internal.platform.Platform -import java.io.File -import java.net.Socket -import java.security.KeyManagementException -import java.security.NoSuchAlgorithmException -import java.security.Principal -import java.security.PrivateKey -import java.security.cert.X509Certificate -import java.util.concurrent.TimeUnit -import javax.inject.Singleton -import javax.net.ssl.SSLContext -import javax.net.ssl.X509KeyManager -import javax.net.ssl.X509TrustManager - -/** - * Provides singleton [OkHttpClient] for the application. - */ -@Module -@InstallIn(SingletonComponent::class) -object OkHttpClientModule { - - @Provides - @Singleton - fun provideOkHttpClient( - @ApplicationContext context: Context, - ): OkHttpClient = cachingHttpClient( - context = context, - cacheDirectory = context.cacheDir.resolve("http") - ).newBuilder() - .addNetworkInterceptor(UserAgentInterceptor) - .build() -} - -fun cachingHttpClient( - context: Context, - cacheDirectory: File? = null, - cacheSize: Long = 10L * 1024L * 1024L, - trustAllCerts: Boolean = true, - connectTimeoutSecs: Long = 30L, - readTimeoutSecs: Long = 30L, - clientCertificateAlias: String? = null, -): OkHttpClient { - val builder: OkHttpClient.Builder = OkHttpClient.Builder() - - if (cacheDirectory != null) { - builder.cache(Cache(cacheDirectory, cacheSize)) - } - - builder - .connectTimeout(connectTimeoutSecs, TimeUnit.SECONDS) - .readTimeout(readTimeoutSecs, TimeUnit.SECONDS) - .followRedirects(true) - - if (!clientCertificateAlias.isNullOrBlank() || trustAllCerts) { - builder.setupSsl(context, clientCertificateAlias, trustAllCerts) - } - - return builder.build() -} - -fun OkHttpClient.Builder.setupSsl( - context: Context, - clientCertificateAlias: String?, - trustAllCerts: Boolean -) { - try { - val clientKeyManager = clientCertificateAlias?.let { clientAlias -> - object : X509KeyManager { - override fun getClientAliases(keyType: String?, issuers: Array?) = - throw UnsupportedOperationException("getClientAliases") - - override fun chooseClientAlias( - keyType: Array?, - issuers: Array?, - socket: Socket? - ) = clientCertificateAlias - - override fun getServerAliases(keyType: String?, issuers: Array?) = - throw UnsupportedOperationException("getServerAliases") - - override fun chooseServerAlias( - keyType: String?, - issuers: Array?, - socket: Socket? - ) = throw UnsupportedOperationException("chooseServerAlias") - - override fun getCertificateChain(alias: String?): Array? { - return if (alias == clientAlias) KeyChain.getCertificateChain(context, clientAlias) else null - } - - override fun getPrivateKey(alias: String?): PrivateKey? { - return if (alias == clientAlias) KeyChain.getPrivateKey(context, clientAlias) else null - } - } - } - - val trustManager = if (trustAllCerts) { - hostnameVerifier { _, _ -> true } - - @SuppressLint("CustomX509TrustManager") - object : X509TrustManager { - override fun checkClientTrusted( - chain: Array?, - authType: String? - ) = Unit - - override fun checkServerTrusted( - chain: Array?, - authType: String? - ) = Unit - - override fun getAcceptedIssuers(): Array = emptyArray() - } - } else { - Platform.get().platformTrustManager() - } - - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(arrayOf(clientKeyManager), arrayOf(trustManager), null) - val sslSocketFactory = sslContext.socketFactory - - sslSocketFactory(sslSocketFactory, trustManager) - } catch (e: NoSuchAlgorithmException) { - // ignore - } catch (e: KeyManagementException) { - // ignore - } -} - -object UserAgentInterceptor : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - return chain.proceed( - chain.request() - .newBuilder() - .header("User-Agent", USER_AGENT_STRING) - .build() - ) - } -} - -const val USER_AGENT_STRING = BuildConfig.USER_AGENT_STRING \ No newline at end of file +/* + * Feeder: Android RSS reader app + * https://gitlab.com/spacecowboy/Feeder + * + * Copyright (C) 2022 Jonas Kalderstam + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package me.ash.reader.infrastructure.di + +import android.annotation.SuppressLint +import android.content.Context +import android.security.KeyChain +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import me.ash.reader.BuildConfig +import okhttp3.Cache +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.internal.platform.Platform +import java.io.File +import java.net.Socket +import java.security.KeyManagementException +import java.security.NoSuchAlgorithmException +import java.security.Principal +import java.security.PrivateKey +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.inject.Singleton +import javax.net.ssl.SSLContext +import javax.net.ssl.X509KeyManager +import javax.net.ssl.X509TrustManager + +/** + * Provides singleton [OkHttpClient] for the application. + */ +@Module +@InstallIn(SingletonComponent::class) +object OkHttpClientModule { + + @Provides + @Singleton + fun provideOkHttpClient( + @ApplicationContext context: Context, + ): OkHttpClient = cachingHttpClient( + context = context, + cacheDirectory = context.cacheDir.resolve("http") + ).newBuilder() + .addInterceptor(UserAgentInterceptor) + .addNetworkInterceptor(UserAgentInterceptor) + .build() +} + +fun cachingHttpClient( + context: Context, + cacheDirectory: File? = null, + cacheSize: Long = 10L * 1024L * 1024L, + trustAllCerts: Boolean = true, + connectTimeoutSecs: Long = 30L, + readTimeoutSecs: Long = 30L, + clientCertificateAlias: String? = null, +): OkHttpClient { + val builder: OkHttpClient.Builder = OkHttpClient.Builder() + + if (cacheDirectory != null) { + builder.cache(Cache(cacheDirectory, cacheSize)) + } + + builder + .addInterceptor(UserAgentInterceptor) + .connectTimeout(connectTimeoutSecs, TimeUnit.SECONDS) + .readTimeout(readTimeoutSecs, TimeUnit.SECONDS) + .followRedirects(true) + + if (!clientCertificateAlias.isNullOrBlank() || trustAllCerts) { + builder.setupSsl(context, clientCertificateAlias, trustAllCerts) + } + + return builder.build() +} + +fun OkHttpClient.Builder.setupSsl( + context: Context, + clientCertificateAlias: String?, + trustAllCerts: Boolean +) { + try { + val clientKeyManager = clientCertificateAlias?.let { clientAlias -> + object : X509KeyManager { + override fun getClientAliases(keyType: String?, issuers: Array?) = + throw UnsupportedOperationException("getClientAliases") + + override fun chooseClientAlias( + keyType: Array?, + issuers: Array?, + socket: Socket? + ) = clientCertificateAlias + + override fun getServerAliases(keyType: String?, issuers: Array?) = + throw UnsupportedOperationException("getServerAliases") + + override fun chooseServerAlias( + keyType: String?, + issuers: Array?, + socket: Socket? + ) = throw UnsupportedOperationException("chooseServerAlias") + + override fun getCertificateChain(alias: String?): Array? { + return if (alias == clientAlias) KeyChain.getCertificateChain(context, clientAlias) else null + } + + override fun getPrivateKey(alias: String?): PrivateKey? { + return if (alias == clientAlias) KeyChain.getPrivateKey(context, clientAlias) else null + } + } + } + + val trustManager = if (trustAllCerts) { + hostnameVerifier { _, _ -> true } + + @SuppressLint("CustomX509TrustManager") + object : X509TrustManager { + override fun checkClientTrusted( + chain: Array?, + authType: String? + ) = Unit + + override fun checkServerTrusted( + chain: Array?, + authType: String? + ) = Unit + + override fun getAcceptedIssuers(): Array = emptyArray() + } + } else { + Platform.get().platformTrustManager() + } + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(arrayOf(clientKeyManager), arrayOf(trustManager), null) + val sslSocketFactory = sslContext.socketFactory + + sslSocketFactory(sslSocketFactory, trustManager) + } catch (e: NoSuchAlgorithmException) { + // ignore + } catch (e: KeyManagementException) { + // ignore + } +} + +object UserAgentInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val existingUa = request.header("User-Agent") + val userAgent = if (existingUa.isNullOrBlank() || existingUa.startsWith("okhttp", ignoreCase = true)) { + USER_AGENT_STRING + } else { + existingUa + } + return chain.proceed( + request + .newBuilder() + .header("User-Agent", userAgent) + .build() + ) + } +} + +const val USER_AGENT_STRING = + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36" \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/BestIconFinder.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/BestIconFinder.kt index 87da0ac7d..06fddcf5e 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/rss/BestIconFinder.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/BestIconFinder.kt @@ -10,15 +10,15 @@ class BestIconFinder(private val client: OkHttpClient) { private val defaultFormats = listOf("apple-touch-icon", "svg", "png", "ico", "gif", "jpg") - suspend fun findBestIcon(siteUrl: String): String? { + suspend fun findBestIcon(siteUrl: String): String? = runCatching { val url = normalizeUrl(siteUrl) val icons = fetchIcons(url) - return selectBestIcon(icons) - } + selectBestIcon(icons) + }.getOrNull() private fun normalizeUrl(url: String): String { return if (!url.startsWith("http://") && !url.startsWith("https://")) { - "http://$url" + "https://$url" } else { url } @@ -34,7 +34,7 @@ class BestIconFinder(private val client: OkHttpClient) { defaultIconUrls(url) } - return links.mapNotNull { fetchIconDetails(it) } + return links.take(4).mapNotNull { fetchIconDetails(it) } } private suspend fun fetchHtml(url: String): String { diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt index acfb68e9e..3ec91d34e 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt @@ -55,22 +55,28 @@ constructor( suspend fun searchFeed(feedLink: String): SearchFeedResult { return withContext(ioDispatcher) { val directResponse = response(okHttpClient, feedLink) - if (!directResponse.commonIsSuccessful) throw IOException(directResponse.message) val directBody = directResponse.body.bytes() val directHttpContentType = toHttpContentType(directResponse.header("Content-Type")) - val parsedDirectFeed = runCatching { parseFeed(directBody, directHttpContentType) }.getOrNull() + val parsedDirectFeed = if (directResponse.commonIsSuccessful) { + runCatching { parseFeed(directBody, directHttpContentType) }.getOrNull() + } else null val resolvedFeedLink = if (parsedDirectFeed != null) feedLink else discoverFeedLink(feedLink, directBody) - ?: throw IOException("Unable to detect RSS feed URL") - + ?: throw IOException( + if (!directResponse.commonIsSuccessful) { + "HTTP ${directResponse.code}: ${directResponse.message}" + } else { + "Unable to detect RSS feed URL" + } + ) val feed = parsedDirectFeed ?: run { val discoveredResponse = response(okHttpClient, resolvedFeedLink) if (!discoveredResponse.commonIsSuccessful) { - throw IOException(discoveredResponse.message) + throw IOException("HTTP ${discoveredResponse.code}: ${discoveredResponse.message}") } parseFeed( discoveredResponse.body.bytes(), @@ -167,10 +173,14 @@ constructor( feed: Feed, latestLink: String?, preDate: Date = Date(), - ): List
= - try { + ): List
{ + return try { val accountId = context.currentAccountId val response = response(okHttpClient, feed.url) + if (!response.commonIsSuccessful) { + Log.w("RLog", "queryRssXml[${feed.name}]: HTTP ${response.code} ${response.message}") + return emptyList() + } val contentType = response.header("Content-Type") val httpContentType = @@ -194,6 +204,7 @@ constructor( Log.e("RLog", "queryRssXml[${feed.name}]: ${e.message}") listOf() } + } fun buildArticleFromSyndEntry( feed: Feed, @@ -282,14 +293,14 @@ constructor( return imgRegex.find(text)?.groupValues?.get(2)?.takeIf { !it.startsWith("data:") } } - suspend fun queryRssIconLink(feedLink: String?): String? { - if (feedLink.isNullOrEmpty()) return null + suspend fun queryRssIconLink(feedLink: String?): String? = runCatching { + if (feedLink.isNullOrEmpty()) return@runCatching null val iconFinder = BestIconFinder(okHttpClient) val domain = feedLink.extractDomain() - return iconFinder.findBestIcon(domain ?: feedLink).also { + iconFinder.findBestIcon(domain ?: feedLink).also { Log.i("RLog", "queryRssIconByLink: get $it from $domain") } - } + }.getOrNull() suspend fun saveRssIcon(feedDao: FeedDao, feed: Feed, iconLink: String) { feedDao.update(feed.copy(icon = iconLink)) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt index d2cc67d22..04afc9e3c 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt @@ -144,7 +144,7 @@ constructor( return@launch } val groups = groupsFlow.value - val firstGroupId = groups.firstOrNull()?.id ?: return@launch + val firstGroupId = groups.firstOrNull()?.id ?: "" val job = viewModelScope.launch { diff --git a/app/src/test/java/me/ash/reader/infrastructure/di/OkHttpClientModuleTest.kt b/app/src/test/java/me/ash/reader/infrastructure/di/OkHttpClientModuleTest.kt new file mode 100644 index 000000000..11d84a53b --- /dev/null +++ b/app/src/test/java/me/ash/reader/infrastructure/di/OkHttpClientModuleTest.kt @@ -0,0 +1,90 @@ +package me.ash.reader.infrastructure.di + +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class OkHttpClientModuleTest { + + @Test + fun testUserAgentStringIsBrowserCompatible() { + assertTrue(USER_AGENT_STRING.startsWith("Mozilla/5.0")) + assertTrue(USER_AGENT_STRING.contains("Mobile")) + assertTrue(!USER_AGENT_STRING.contains("ReadYou")) + } + + @Test + fun testUserAgentInterceptorAppliesDefaultUserAgent() { + var interceptedRequest: Request? = null + + val fakeChain = object : Interceptor.Chain { + override fun request(): Request = Request.Builder().url("https://example.com/rss.xml").build() + + override fun proceed(request: Request): Response { + interceptedRequest = request + return Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("".toResponseBody()) + .build() + } + + override fun call(): okhttp3.Call = throw NotImplementedError() + override fun connection(): okhttp3.Connection? = null + override fun connectTimeoutMillis(): Int = 0 + override fun readTimeoutMillis(): Int = 0 + override fun writeTimeoutMillis(): Int = 0 + override fun withConnectTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit): Interceptor.Chain = this + override fun withReadTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit): Interceptor.Chain = this + override fun withWriteTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit): Interceptor.Chain = this + } + + UserAgentInterceptor.intercept(fakeChain) + + assertEquals(USER_AGENT_STRING, interceptedRequest?.header("User-Agent")) + } + + @Test + fun testUserAgentInterceptorPreservesCustomUserAgent() { + var interceptedRequest: Request? = null + val customUa = "CustomApp/1.0" + + val fakeChain = object : Interceptor.Chain { + override fun request(): Request = Request.Builder() + .url("https://example.com/rss.xml") + .header("User-Agent", customUa) + .build() + + override fun proceed(request: Request): Response { + interceptedRequest = request + return Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("".toResponseBody()) + .build() + } + + override fun call(): okhttp3.Call = throw NotImplementedError() + override fun connection(): okhttp3.Connection? = null + override fun connectTimeoutMillis(): Int = 0 + override fun readTimeoutMillis(): Int = 0 + override fun writeTimeoutMillis(): Int = 0 + override fun withConnectTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit): Interceptor.Chain = this + override fun withReadTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit): Interceptor.Chain = this + override fun withWriteTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit): Interceptor.Chain = this + } + + UserAgentInterceptor.intercept(fakeChain) + + assertEquals(customUa, interceptedRequest?.header("User-Agent")) + } +} diff --git a/app/src/test/java/me/ash/reader/infrastructure/rss/RssHelperTest.kt b/app/src/test/java/me/ash/reader/infrastructure/rss/RssHelperTest.kt index 6cfccb310..35a592d3b 100644 --- a/app/src/test/java/me/ash/reader/infrastructure/rss/RssHelperTest.kt +++ b/app/src/test/java/me/ash/reader/infrastructure/rss/RssHelperTest.kt @@ -87,4 +87,62 @@ class RssHelperTest { """ Assert.assertEquals(imageUrlString, rssHelper.findThumbnail(case)) } + + @Test + fun testParseRss20Feed() { + val sampleFeedXml = """ + + + + Phoronix + https://www.phoronix.com/ + Linux Hardware Reviews, Performance Benchmarks + en-us + + Sample Linux Article + https://www.phoronix.com/news/sample-linux-article + https://www.phoronix.com/news/sample-linux-article + Sample article description. + Sun, 23 Aug 2026 07:41:04 -0400 + Michael Larabel + + + + """.trimIndent() + + val inputStream = java.io.ByteArrayInputStream(sampleFeedXml.toByteArray(Charsets.UTF_8)) + val syndFeed = com.rometools.rome.io.SyndFeedInput().build(com.rometools.rome.io.XmlReader(inputStream, "text/xml; charset=UTF-8")) + Assert.assertEquals("Phoronix", syndFeed.title) + Assert.assertEquals(1, syndFeed.entries.size) + Assert.assertEquals("Sample Linux Article", syndFeed.entries[0].title) + Assert.assertEquals("Michael Larabel", syndFeed.entries[0].author) + } + + @Test + fun testRealSearchFeedPhoronix() { + val client = okhttp3.OkHttpClient.Builder() + .addNetworkInterceptor(me.ash.reader.infrastructure.di.UserAgentInterceptor) + .build() + val helper = RssHelper(mockContext, kotlinx.coroutines.Dispatchers.IO, client) + kotlinx.coroutines.runBlocking { + val result = helper.searchFeed("https://www.phoronix.com/rss.php") + Assert.assertNotNull(result.feed) + Assert.assertEquals("Phoronix", result.feed.title) + Assert.assertEquals("https://www.phoronix.com/rss.php", result.feedLink) + } + } + + @Test + fun testRealDiscoverFeedPhoronix() { + val client = okhttp3.OkHttpClient.Builder() + .addNetworkInterceptor(me.ash.reader.infrastructure.di.UserAgentInterceptor) + .build() + val helper = RssHelper(mockContext, kotlinx.coroutines.Dispatchers.IO, client) + kotlinx.coroutines.runBlocking { + val result = helper.searchFeed("https://www.phoronix.com") + Assert.assertNotNull(result.feed) + Assert.assertEquals("Phoronix", result.feed.title) + Assert.assertEquals("https://www.phoronix.com/rss.php", result.feedLink) + } + } }