From 15ad056b0f882c0e4144507cf925e990f839c6fe Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Tue, 1 Oct 2024 21:17:06 -0500 Subject: [PATCH] Add JSON feed handler --- rssparser/build.gradle.kts | 4 + .../kotlin/com/prof18/rssparser/RssParser.kt | 37 +- .../com/prof18/rssparser/RssParserBuilder.kt | 9 +- .../rssparser/internal/DefaultFetcher.kt | 54 ++ .../rssparser/internal/DefaultParser.kt | 91 +++ .../rssparser/internal/DefaultXmlFetcher.kt | 83 --- .../rssparser/internal/DefaultXmlParser.kt | 62 -- .../com/prof18/rssparser/internal/Fetcher.kt | 5 + .../com/prof18/rssparser/internal/Parser.kt | 7 + .../prof18/rssparser/internal/ParserInput.kt | 10 +- .../prof18/rssparser/internal/XmlFetcher.kt | 6 - .../prof18/rssparser/internal/XmlParser.kt | 8 - .../internal/atom/AtomFeedHandler.kt | 2 +- .../internal/{ => atom}/AtomKeyword.kt | 2 +- .../internal/json/JsonFeedHandler.kt | 42 ++ .../rssparser/internal/json/models/Author.kt | 10 + .../rssparser/internal/json/models/Feed.kt | 21 + .../rssparser/internal/json/models/Hub.kt | 9 + .../rssparser/internal/json/models/Item.kt | 21 + .../rssparser/internal/rdf/RdfFeedHandler.kt | 5 +- .../internal/{ => rdf}/RdfKeyword.kt | 2 +- .../rssparser/internal/rss/RssFeedHandler.kt | 1 - .../internal/{ => rss}/RssKeyword.kt | 2 +- .../com/prof18/rssparser/model/RssImage.kt | 6 +- ...BaseXmlParserTest.kt => BaseParserTest.kt} | 9 +- .../rssparser/MalformedFeedParserTest.kt | 11 +- .../com/prof18/rssparser/ParserFactory.kt | 11 + .../kotlin/com/prof18/rssparser/TestUtils.kt | 11 +- .../com/prof18/rssparser/XmlParserFactory.kt | 11 - .../XmlParserAtomCategoryAttributeTest.kt | 4 +- .../atom/XmlParserAtomContentHtmlTest.kt | 4 +- .../atom/XmlParserAtomExampleTest.kt | 4 +- .../atom/XmlParserAtomFeedCreatedUpdated.kt | 4 +- .../atom/XmlParserAtomImageQueryParams.kt | 4 +- .../atom/XmlParserAtomRepliesLink.kt | 4 +- .../rssparser/atom/XmlParserAtomSelfLink.kt | 4 +- .../rssparser/atom/XmlParserAtomTest.kt | 4 +- .../rssparser/json/JsonKibtyTownTest.kt | 16 + .../com/prof18/rssparser/json/JsonNprTest.kt | 27 + .../rdf/XmlParserRdfDistroWatchTest.kt | 4 +- .../prof18/rssparser/rdf/XmlParserRdfTest.kt | 4 +- .../rssparser/rss/XmlParserAudioFeedTest.kt | 4 +- .../rssparser/rss/XmlParserBingFeedImage.kt | 4 +- .../rssparser/rss/XmlParserCharEscape.kt | 4 +- .../rssparser/rss/XmlParserCharsetFeedTest.kt | 4 +- .../rssparser/rss/XmlParserCommentsTest.kt | 4 +- .../rssparser/rss/XmlParserFeedRuTest.kt | 4 +- .../rssparser/rss/XmlParserFeedThumbTest.kt | 4 +- .../rssparser/rss/XmlParserGreekTest.kt | 4 +- .../rss/XmlParserHindiChannelImageTest.kt | 4 +- .../rssparser/rss/XmlParserImage2FeedTest.kt | 4 +- .../rss/XmlParserImageChannelReverseTest.kt | 4 +- .../rssparser/rss/XmlParserImageEmptyTag.kt | 5 +- .../rssparser/rss/XmlParserImageEnclosure.kt | 4 +- .../rssparser/rss/XmlParserImageFeedTest.kt | 4 +- .../rssparser/rss/XmlParserImageLinkTest.kt | 4 +- .../rss/XmlParserItemChannelImageTest.kt | 4 +- .../rssparser/rss/XmlParserItunesFeedTest.kt | 4 +- .../rss/XmlParserItunesSeasonFeedTest.kt | 4 +- .../rss/XmlParserMultipleImageAndVideo.kt | 4 +- .../rssparser/rss/XmlParserSourceTest.kt | 4 +- .../rss/XmlParserStandardFeedTest.kt | 4 +- .../rssparser/rss/XmlParserTimeFeedTest.kt | 4 +- .../rss/XmlParserUnexpectedTokenTest.kt | 4 +- .../rssparser/rss/XmlParserXSLFeedTest.kt | 4 +- .../src/test/resources/feed-kibty-town.json | 51 ++ .../src/test/resources/feed-npr-world.json | 551 ++++++++++++++++++ 67 files changed, 1033 insertions(+), 301 deletions(-) create mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultFetcher.kt create mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultParser.kt delete mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultXmlFetcher.kt delete mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultXmlParser.kt create mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/Fetcher.kt create mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/Parser.kt delete mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/XmlFetcher.kt delete mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/XmlParser.kt rename rssparser/src/main/kotlin/com/prof18/rssparser/internal/{ => atom}/AtomKeyword.kt (96%) create mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/JsonFeedHandler.kt create mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Author.kt create mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Feed.kt create mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Hub.kt create mode 100644 rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Item.kt rename rssparser/src/main/kotlin/com/prof18/rssparser/internal/{ => rdf}/RdfKeyword.kt (96%) rename rssparser/src/main/kotlin/com/prof18/rssparser/internal/{ => rss}/RssKeyword.kt (98%) rename rssparser/src/test/kotlin/com/prof18/rssparser/{BaseXmlParserTest.kt => BaseParserTest.kt} (97%) create mode 100644 rssparser/src/test/kotlin/com/prof18/rssparser/ParserFactory.kt delete mode 100644 rssparser/src/test/kotlin/com/prof18/rssparser/XmlParserFactory.kt create mode 100644 rssparser/src/test/kotlin/com/prof18/rssparser/json/JsonKibtyTownTest.kt create mode 100644 rssparser/src/test/kotlin/com/prof18/rssparser/json/JsonNprTest.kt create mode 100644 rssparser/src/test/resources/feed-kibty-town.json create mode 100644 rssparser/src/test/resources/feed-npr-world.json diff --git a/rssparser/build.gradle.kts b/rssparser/build.gradle.kts index 7f066932..63eea33b 100644 --- a/rssparser/build.gradle.kts +++ b/rssparser/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("java-library") + id("com.google.devtools.ksp") version libs.versions.ksp alias(libs.plugins.jetbrains.kotlin.jvm) } @@ -12,6 +13,9 @@ dependencies { implementation(libs.okhttp.client) implementation(libs.kotlinx.coroutines.core) implementation(libs.jsoup) + implementation(libs.moshi) + implementation(libs.moshi.converter) + ksp(libs.moshi.kotlin.codegen) testImplementation(kotlin("test")) testImplementation(kotlin("test-common")) testImplementation(kotlin("test-annotations-common")) diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/RssParser.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/RssParser.kt index 94b98c84..2ed303f7 100644 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/RssParser.kt +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/RssParser.kt @@ -1,8 +1,8 @@ package com.prof18.rssparser -import com.prof18.rssparser.exception.RssParsingException -import com.prof18.rssparser.internal.XmlFetcher -import com.prof18.rssparser.internal.XmlParser +import com.prof18.rssparser.internal.Fetcher +import com.prof18.rssparser.internal.Parser +import com.prof18.rssparser.internal.ParserInput import com.prof18.rssparser.model.RssChannel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -10,42 +10,35 @@ import kotlinx.coroutines.withContext import kotlin.coroutines.CoroutineContext class RssParser internal constructor( - private val xmlFetcher: XmlFetcher, - private val xmlParser: XmlParser, + private val fetcher: Fetcher, + private val parser: Parser, ) { - private val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default internal interface Builder { - /** - * Creates a [RssParser] object - */ fun build(): RssParser } /** * Downloads and parses an RSS feed from an [url] and returns an [RssChannel]. - * - * If the parsing fails because the XML is malformed, it will re-download the XML as a string, - * clean it up and try to parse it again. If it fails again, it will throw an [RssParsingException]. */ suspend fun getRssChannel(url: String): RssChannel = withContext(coroutineContext) { - val parserInput = xmlFetcher.fetchXml(url) - return@withContext try { - xmlParser.parseXML(parserInput) - } catch (_: RssParsingException) { - val xmlAsString = xmlFetcher.fetchXmlAsString(url) - val input = xmlParser.generateParserInputFromString(xmlAsString) - xmlParser.parseXML(input) - } + val parserInput = fetcher.fetch(url) + return@withContext parser.parse(parserInput) } /** * Parses an RSS feed provided by [rawRssFeed] and returns an [RssChannel] */ suspend fun parse(rawRssFeed: String): RssChannel = withContext(coroutineContext) { - val parserInput = xmlParser.generateParserInputFromString(rawRssFeed) - return@withContext xmlParser.parseXML(parserInput) + val parserInput = generateParserInputFromString(rawRssFeed) + return@withContext parser.parse(parserInput) + } + + private fun generateParserInputFromString(rawRssFeed: String): ParserInput { + val cleanedXml = rawRssFeed.trim() + val inputStream = cleanedXml.byteInputStream(Charsets.UTF_8) + return ParserInput.from(inputStream) } } diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/RssParserBuilder.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/RssParserBuilder.kt index 540a1ee4..1cfbdc27 100644 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/RssParserBuilder.kt +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/RssParserBuilder.kt @@ -1,7 +1,7 @@ package com.prof18.rssparser -import com.prof18.rssparser.internal.DefaultXmlFetcher -import com.prof18.rssparser.internal.DefaultXmlParser +import com.prof18.rssparser.internal.DefaultFetcher +import com.prof18.rssparser.internal.DefaultParser import kotlinx.coroutines.Dispatchers import okhttp3.Call import okhttp3.OkHttpClient @@ -23,11 +23,10 @@ class RssParserBuilder( override fun build(): RssParser { val client = callFactory return RssParser( - xmlFetcher = DefaultXmlFetcher( + fetcher = DefaultFetcher( callFactory = client, ), - xmlParser = DefaultXmlParser( - charset = charset, + parser = DefaultParser( dispatcher = Dispatchers.IO, ), ) diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultFetcher.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultFetcher.kt new file mode 100644 index 00000000..37307e00 --- /dev/null +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultFetcher.kt @@ -0,0 +1,54 @@ +package com.prof18.rssparser.internal + +import com.prof18.rssparser.exception.HttpException +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Request +import okhttp3.Response +import java.io.IOException +import java.io.InputStream +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal class DefaultFetcher( + private val callFactory: Call.Factory, +) : Fetcher { + override suspend fun fetch(url: String): ParserInput { + val request = createRequest(url) + return callFactory.newCall(request).awaitForInputStream() + } + + private fun createRequest(url: String): Request = + Request.Builder() + .url(url) + .build() + + private suspend fun Call.awaitForInputStream(): ParserInput = + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { + cancel() + } + + enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + val body = requireNotNull(response.body) + continuation.resume( + ParserInput(body.bytes()) + ) + } else { + val exception = HttpException( + code = response.code, + message = response.message, + ) + continuation.resumeWithException(exception) + } + } + + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + }) + } +} diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultParser.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultParser.kt new file mode 100644 index 00000000..e340f4f5 --- /dev/null +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultParser.kt @@ -0,0 +1,91 @@ +package com.prof18.rssparser.internal + +import com.prof18.rssparser.exception.RssParsingException +import com.prof18.rssparser.internal.atom.AtomFeedHandler +import com.prof18.rssparser.internal.atom.AtomKeyword +import com.prof18.rssparser.internal.json.JsonFeedHandler +import com.prof18.rssparser.internal.json.models.Feed +import com.prof18.rssparser.internal.rdf.RdfFeedHandler +import com.prof18.rssparser.internal.rdf.RdfKeyword +import com.prof18.rssparser.internal.rss.RssFeedHandler +import com.prof18.rssparser.internal.rss.RssKeyword +import com.prof18.rssparser.model.RssChannel +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import okio.IOException +import okio.buffer +import okio.source +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.parser.Parser as JsoupParser + +internal class DefaultParser( + private val dispatcher: CoroutineDispatcher, +) : Parser { + override suspend fun parse(input: ParserInput): RssChannel { + return withContext(dispatcher) { + + val handler = findHandler(input) + + if (handler == null) { + throw RssParsingException( + message = "Could not find top-level RSS node", + cause = null + ) + } + + handler.build() + + } + } + + private fun findHandler(input: ParserInput): FeedHandler? { + val document = tryXmlParse(input) ?: return null + + val handler = document.children().firstNotNullOfOrNull { node -> + when (node.tagName()) { + RssKeyword.Rss.value -> { + RssFeedHandler(document) + } + + AtomKeyword.Atom.value -> { + AtomFeedHandler(node) + } + + RdfKeyword.Rdf.value -> { + RdfFeedHandler(node) + } + + else -> tryParseJson(input) + } + } + + return handler ?: tryParseJson(input) + } +} + +private fun tryXmlParse(input: ParserInput): Document? { + return try { + Jsoup.parse(input.inputStream(), null, "", JsoupParser.xmlParser()) + } catch (e: IOException) { + null + } +} + +@OptIn(ExperimentalStdlibApi::class) +private fun tryParseJson(input: ParserInput): FeedHandler? { + return try { + val moshi = Moshi + .Builder() + .build() + + val feed = moshi.adapter() + .fromJson(input.inputStream().source().buffer()) ?: return null + + JsonFeedHandler(feed) + } catch (e: IOException) { + null + } +} diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultXmlFetcher.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultXmlFetcher.kt deleted file mode 100644 index 0cb01b48..00000000 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultXmlFetcher.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.prof18.rssparser.internal - -import com.prof18.rssparser.exception.HttpException -import kotlinx.coroutines.suspendCancellableCoroutine -import okhttp3.Call -import okhttp3.Callback -import okhttp3.Request -import okhttp3.Response -import java.io.IOException -import java.io.InputStream -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -internal class DefaultXmlFetcher( - private val callFactory: Call.Factory, -) : XmlFetcher { - override suspend fun fetchXml(url: String): ParserInput { - val request = createRequest(url) - return ParserInput( - inputStream = callFactory.newCall(request).awaitForInputStream() - ) - } - - override suspend fun fetchXmlAsString(url: String): String { - val request = createRequest(url) - return callFactory.newCall(request).awaitForString() - } - - private fun createRequest(url: String): Request = - Request.Builder() - .url(url) - .build() - - private suspend fun Call.awaitForInputStream(): InputStream = suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { - cancel() - } - - enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - val body = requireNotNull(response.body) - continuation.resume(body.byteStream()) - } else { - val exception = HttpException( - code = response.code, - message = response.message, - ) - continuation.resumeWithException(exception) - } - } - - override fun onFailure(call: Call, e: IOException) { - continuation.resumeWithException(e) - } - }) - } - - private suspend fun Call.awaitForString(): String = suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { - cancel() - } - - enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - val body = requireNotNull(response.body) - continuation.resume(body.string()) - } else { - val exception = HttpException( - code = response.code, - message = response.message, - ) - continuation.resumeWithException(exception) - } - } - - override fun onFailure(call: Call, e: IOException) { - continuation.resumeWithException(e) - } - }) - } -} diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultXmlParser.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultXmlParser.kt deleted file mode 100644 index 83d9bfed..00000000 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/DefaultXmlParser.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.prof18.rssparser.internal - -import com.prof18.rssparser.exception.RssParsingException -import com.prof18.rssparser.internal.atom.AtomFeedHandler -import com.prof18.rssparser.internal.rdf.RdfFeedHandler -import com.prof18.rssparser.internal.rss.RssFeedHandler -import com.prof18.rssparser.model.RssChannel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import okhttp3.internal.closeQuietly -import org.jsoup.Jsoup -import org.jsoup.parser.Parser -import java.io.InputStream -import java.nio.charset.Charset - -internal class DefaultXmlParser( - private val charset: Charset? = null, - private val dispatcher: CoroutineDispatcher, -) : XmlParser { - override suspend fun parseXML(input: ParserInput): RssChannel { - return withContext(dispatcher) { - try { - val document = Jsoup.parse(input.inputStream, null, "", Parser.xmlParser()) - - val handler = document.children().firstNotNullOfOrNull { node -> - when (node.tagName()) { - RssKeyword.Rss.value -> { - RssFeedHandler(document) - } - - AtomKeyword.Atom.value -> { - AtomFeedHandler(node) - } - - RdfKeyword.Rdf.value -> { - RdfFeedHandler(node) - } - - else -> null - } - } - - if (handler == null) { - throw RssParsingException( - message = "Could not find top-level RSS node", - cause = null - ) - } - - handler.build() - } finally { - input.inputStream.closeQuietly() - } - } - } - - override fun generateParserInputFromString(rawRssFeed: String): ParserInput { - val cleanedXml = rawRssFeed.trim() - val inputStream: InputStream = cleanedXml.byteInputStream(charset ?: Charsets.UTF_8) - return ParserInput(inputStream) - } -} diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/Fetcher.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/Fetcher.kt new file mode 100644 index 00000000..af4c9e83 --- /dev/null +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/Fetcher.kt @@ -0,0 +1,5 @@ +package com.prof18.rssparser.internal + +internal interface Fetcher { + suspend fun fetch(url: String): ParserInput +} diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/Parser.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/Parser.kt new file mode 100644 index 00000000..edd2d3d5 --- /dev/null +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/Parser.kt @@ -0,0 +1,7 @@ +package com.prof18.rssparser.internal + +import com.prof18.rssparser.model.RssChannel + +internal interface Parser { + suspend fun parse(input: ParserInput): RssChannel +} diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/ParserInput.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/ParserInput.kt index d1fd3f9a..15b2dfb4 100644 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/ParserInput.kt +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/ParserInput.kt @@ -2,6 +2,10 @@ package com.prof18.rssparser.internal import java.io.InputStream -internal data class ParserInput( - val inputStream: InputStream -) +internal class ParserInput(private val bytes: ByteArray) { + fun inputStream() = bytes.inputStream() + + companion object { + fun from(inputStream: InputStream) = ParserInput(inputStream.readBytes()) + } +} diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/XmlFetcher.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/XmlFetcher.kt deleted file mode 100644 index 0209f7b2..00000000 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/XmlFetcher.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.prof18.rssparser.internal - -internal interface XmlFetcher { - suspend fun fetchXml(url: String): ParserInput - suspend fun fetchXmlAsString(url: String): String -} diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/XmlParser.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/XmlParser.kt deleted file mode 100644 index 1de69397..00000000 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/XmlParser.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.prof18.rssparser.internal - -import com.prof18.rssparser.model.RssChannel - -internal interface XmlParser { - suspend fun parseXML(input: ParserInput): RssChannel - fun generateParserInputFromString(rawRssFeed: String): ParserInput -} diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/atom/AtomFeedHandler.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/atom/AtomFeedHandler.kt index 3c1a789c..38f7f7c6 100644 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/atom/AtomFeedHandler.kt +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/atom/AtomFeedHandler.kt @@ -1,6 +1,6 @@ package com.prof18.rssparser.internal.atom -import com.prof18.rssparser.internal.AtomKeyword.* +import com.prof18.rssparser.internal.atom.AtomKeyword.* import com.prof18.rssparser.internal.ChannelFactory import com.prof18.rssparser.internal.FeedHandler import com.prof18.rssparser.model.RssChannel diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/AtomKeyword.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/atom/AtomKeyword.kt similarity index 96% rename from rssparser/src/main/kotlin/com/prof18/rssparser/internal/AtomKeyword.kt rename to rssparser/src/main/kotlin/com/prof18/rssparser/internal/atom/AtomKeyword.kt index c37eff76..c78465de 100644 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/AtomKeyword.kt +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/atom/AtomKeyword.kt @@ -1,4 +1,4 @@ -package com.prof18.rssparser.internal +package com.prof18.rssparser.internal.atom internal sealed class AtomKeyword(val value: String) { data object Atom : AtomKeyword("feed") diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/JsonFeedHandler.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/JsonFeedHandler.kt new file mode 100644 index 00000000..f9efa257 --- /dev/null +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/JsonFeedHandler.kt @@ -0,0 +1,42 @@ +package com.prof18.rssparser.internal.json + +import com.prof18.rssparser.internal.ChannelFactory +import com.prof18.rssparser.internal.FeedHandler +import com.prof18.rssparser.internal.json.models.Feed +import com.prof18.rssparser.internal.json.models.Item +import com.prof18.rssparser.model.RssChannel +import com.prof18.rssparser.model.RssImage + +internal class JsonFeedHandler(private val feed: Feed) : FeedHandler { + private var channelFactory = ChannelFactory() + + override fun build(): RssChannel { + channel() + + feed.items.forEach { item(it) } + + return channelFactory.build() + } + + private fun channel() { + channelFactory.channelBuilder.apply { + title(feed.title) + link(feed.home_page_url) + description(feed.description) + image(RssImage(url = feed.icon)) + } + } + + private fun item(item: Item) { + channelFactory.articleBuilder.apply { + title(item.title) + link(item.url) + description(item.summary) + content(item.content_html ?: item.content_text) + pubDate(item.date_published) + image(item.image) + } + + channelFactory.buildArticle() + } +} diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Author.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Author.kt new file mode 100644 index 00000000..82fffd9b --- /dev/null +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Author.kt @@ -0,0 +1,10 @@ +package com.prof18.rssparser.internal.json.models + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class Author( + val name: String?, + val url: String?, + val avatar: String?, +) diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Feed.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Feed.kt new file mode 100644 index 00000000..e9312fd4 --- /dev/null +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Feed.kt @@ -0,0 +1,21 @@ +package com.prof18.rssparser.internal.json.models + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class Feed( + val version: String, + val title: String, + val home_page_url: String?, + val feed_url: String?, + val description: String?, + val user_comment: String?, + val next_url: String?, + val icon: String?, + val favicon: String?, + val authors: List?, + val language: String?, + val expired: Boolean?, + val hubs: List?, + val items: List, +) diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Hub.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Hub.kt new file mode 100644 index 00000000..1c72823a --- /dev/null +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Hub.kt @@ -0,0 +1,9 @@ +package com.prof18.rssparser.internal.json.models + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class Hub( + val type: String, + val url: String, +) diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Item.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Item.kt new file mode 100644 index 00000000..37e2dbfe --- /dev/null +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/json/models/Item.kt @@ -0,0 +1,21 @@ +package com.prof18.rssparser.internal.json.models + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class Item( + val id: String, + val url: String?, + val external_url: String?, + val title: String?, + val content_html: String?, + val content_text: String?, + val summary: String?, + val image: String?, + val banner_image: String?, + val date_published: String?, + val date_modified: String?, + val authors: List?, + val tags: List?, + val language: String?, +) diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/rdf/RdfFeedHandler.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/rdf/RdfFeedHandler.kt index 3cf9b93a..20e797d8 100644 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/rdf/RdfFeedHandler.kt +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/rdf/RdfFeedHandler.kt @@ -2,8 +2,8 @@ package com.prof18.rssparser.internal.rdf import com.prof18.rssparser.internal.ChannelFactory import com.prof18.rssparser.internal.FeedHandler -import com.prof18.rssparser.internal.RdfKeyword.Channel -import com.prof18.rssparser.internal.RdfKeyword.Item +import com.prof18.rssparser.internal.rdf.RdfKeyword.Channel +import com.prof18.rssparser.internal.rdf.RdfKeyword.Item import com.prof18.rssparser.model.RssChannel import org.jsoup.nodes.Element @@ -11,7 +11,6 @@ internal class RdfFeedHandler(val rdf: Element) : FeedHandler { private var channelFactory = ChannelFactory() override fun build(): RssChannel { - rdf.children().forEach { node -> when (node.tagName()) { Channel.value -> channel(node) diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/RdfKeyword.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/rdf/RdfKeyword.kt similarity index 96% rename from rssparser/src/main/kotlin/com/prof18/rssparser/internal/RdfKeyword.kt rename to rssparser/src/main/kotlin/com/prof18/rssparser/internal/rdf/RdfKeyword.kt index 47ecb5b2..edcb59dd 100644 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/RdfKeyword.kt +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/rdf/RdfKeyword.kt @@ -1,4 +1,4 @@ -package com.prof18.rssparser.internal +package com.prof18.rssparser.internal.rdf /** * RDF Site Summary 1.0 Modules diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/rss/RssFeedHandler.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/rss/RssFeedHandler.kt index 530af1b3..647e5681 100644 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/rss/RssFeedHandler.kt +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/rss/RssFeedHandler.kt @@ -2,7 +2,6 @@ package com.prof18.rssparser.internal.rss import com.prof18.rssparser.internal.ChannelFactory import com.prof18.rssparser.internal.FeedHandler -import com.prof18.rssparser.internal.RssKeyword import com.prof18.rssparser.model.RssChannel import org.jsoup.nodes.Document import org.jsoup.nodes.Element diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/RssKeyword.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/rss/RssKeyword.kt similarity index 98% rename from rssparser/src/main/kotlin/com/prof18/rssparser/internal/RssKeyword.kt rename to rssparser/src/main/kotlin/com/prof18/rssparser/internal/rss/RssKeyword.kt index 1f9e00c4..70b3f5ab 100644 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/internal/RssKeyword.kt +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/internal/rss/RssKeyword.kt @@ -1,4 +1,4 @@ -package com.prof18.rssparser.internal +package com.prof18.rssparser.internal.rss internal sealed class RssKeyword(val value: String) { data object Rss: RssKeyword("rss") diff --git a/rssparser/src/main/kotlin/com/prof18/rssparser/model/RssImage.kt b/rssparser/src/main/kotlin/com/prof18/rssparser/model/RssImage.kt index 73973197..427ead8f 100644 --- a/rssparser/src/main/kotlin/com/prof18/rssparser/model/RssImage.kt +++ b/rssparser/src/main/kotlin/com/prof18/rssparser/model/RssImage.kt @@ -1,10 +1,10 @@ package com.prof18.rssparser.model data class RssImage( - val title: String?, + val title: String? = null, val url: String?, - val link: String?, - val description: String? + val link: String? = null, + val description: String? = null ) { fun isNotEmpty(): Boolean { return !url.isNullOrBlank() || !link.isNullOrBlank() diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/BaseXmlParserTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/BaseParserTest.kt similarity index 97% rename from rssparser/src/test/kotlin/com/prof18/rssparser/BaseXmlParserTest.kt rename to rssparser/src/test/kotlin/com/prof18/rssparser/BaseParserTest.kt index 3c73b8aa..4bac83ef 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/BaseXmlParserTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/BaseParserTest.kt @@ -10,7 +10,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -abstract class BaseXmlParserTest( +abstract class BaseParserTest( val feedPath: String, // Channel Data @@ -39,13 +39,14 @@ abstract class BaseXmlParserTest( val articleCommentsUrl: String? = null, val articleItunesData: ItunesItemData? = null, ) { + private lateinit var channel: RssChannel private lateinit var article: RssItem @BeforeTest fun setUp() = runTest { val input = readFileFromResources(feedPath) - channel = XmlParserFactory.createXmlParser().parseXML(input) + channel = ParserFactory.build().parse(input) article = channel.items[0] } @@ -66,7 +67,7 @@ abstract class BaseXmlParserTest( @Test fun channelImage_isCorrect() { - assertEquals(channelImage, channel.image) + assertEquals(channelImage?.url, channel.image?.url) } @Test @@ -248,7 +249,7 @@ abstract class BaseXmlParserTest( @Test fun articleItunesKeywords_isCorrect() { assertEquals( - articleItunesData?.keywords ?: emptyList(), + articleItunesData?.keywords.orEmpty(), article.itunesItemData?.keywords, ) } diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/MalformedFeedParserTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/MalformedFeedParserTest.kt index 22504163..a9d33db8 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/MalformedFeedParserTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/MalformedFeedParserTest.kt @@ -1,7 +1,7 @@ package com.prof18.rssparser +import com.prof18.rssparser.internal.Fetcher import com.prof18.rssparser.internal.ParserInput -import com.prof18.rssparser.internal.XmlFetcher import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertTrue @@ -10,14 +10,11 @@ class MalformedFeedParserTest { @Test fun whenReceivingAMalformedXmlTheParserWillHandleIt() = runTest { val rssParser = RssParser( - xmlFetcher = object : XmlFetcher { - override suspend fun fetchXml(url: String): ParserInput = + fetcher = object : Fetcher { + override suspend fun fetch(url: String): ParserInput = readFileFromResources("feed-test-malformed.xml") - - override suspend fun fetchXmlAsString(url: String): String = - readFileFromResourcesAsString("feed-test-malformed.xml") }, - xmlParser = XmlParserFactory.createXmlParser() + parser = ParserFactory.build() ) val channel = rssParser.getRssChannel("feed-url") diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/ParserFactory.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/ParserFactory.kt new file mode 100644 index 00000000..91254cea --- /dev/null +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/ParserFactory.kt @@ -0,0 +1,11 @@ +package com.prof18.rssparser + +import com.prof18.rssparser.internal.DefaultParser +import com.prof18.rssparser.internal.Parser +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher + +internal object ParserFactory { + @OptIn(ExperimentalCoroutinesApi::class) + fun build(): Parser = DefaultParser(dispatcher = UnconfinedTestDispatcher()) +} diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/TestUtils.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/TestUtils.kt index 60a9fe99..1f03aaab 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/TestUtils.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/TestUtils.kt @@ -2,20 +2,11 @@ package com.prof18.rssparser import com.prof18.rssparser.internal.ParserInput import java.io.File -import java.io.FileInputStream internal fun readFileFromResources( resourceName: String ): ParserInput { val file = File("src/test/resources/$resourceName") - return ParserInput( - inputStream = FileInputStream(file) - ) -} -internal fun readFileFromResourcesAsString( - resourceName: String -): String { - val file = File("src/test/resources/$resourceName") - return file.readText() + return ParserInput(file.readBytes()) } diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/XmlParserFactory.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/XmlParserFactory.kt deleted file mode 100644 index e7f9fba6..00000000 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/XmlParserFactory.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.prof18.rssparser - -import com.prof18.rssparser.internal.DefaultXmlParser -import com.prof18.rssparser.internal.XmlParser -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher - -internal object XmlParserFactory { - @OptIn(ExperimentalCoroutinesApi::class) - fun createXmlParser(): XmlParser = DefaultXmlParser(dispatcher = UnconfinedTestDispatcher()) -} diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomCategoryAttributeTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomCategoryAttributeTest.kt index a5d9c309..aa3ccae1 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomCategoryAttributeTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomCategoryAttributeTest.kt @@ -17,9 +17,9 @@ package com.prof18.rssparser.atom -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserAtomCategoryAttributeTest : BaseXmlParserTest( +class XmlParserAtomCategoryAttributeTest : BaseParserTest( feedPath = "feed-test-atom-category-attribute.xml", articleImage = "https://a.fsdn.com/sd/twitter_icon_large.png", channelTitle = "Slashdot", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomContentHtmlTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomContentHtmlTest.kt index 471c7294..92d50110 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomContentHtmlTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomContentHtmlTest.kt @@ -1,9 +1,9 @@ package com.prof18.rssparser.atom -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserAtomContentHtmlTest : BaseXmlParserTest( +class XmlParserAtomContentHtmlTest : BaseParserTest( feedPath = "feed-test-atom-content-html.xml", channelTitle = "Jake Wharton", channelLink = "https://jakewharton.com/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomExampleTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomExampleTest.kt index 4bf6e2f0..3de7cc95 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomExampleTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomExampleTest.kt @@ -1,8 +1,8 @@ package com.prof18.rssparser.atom -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserAtomExampleTest: BaseXmlParserTest( +class XmlParserAtomExampleTest: BaseParserTest( feedPath = "atom-test-example.xml", channelTitle = "Example Feed", channelLink = "http://example.org/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomFeedCreatedUpdated.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomFeedCreatedUpdated.kt index 803ce244..c1fd67a6 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomFeedCreatedUpdated.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomFeedCreatedUpdated.kt @@ -17,9 +17,9 @@ package com.prof18.rssparser.atom -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserAtomFeedCreatedUpdated : BaseXmlParserTest( +class XmlParserAtomFeedCreatedUpdated : BaseParserTest( feedPath = "atom-feed-created-updated.xml", channelTitle = "tonsky.me", channelLink = "https://tonsky.me/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomImageQueryParams.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomImageQueryParams.kt index 1f21dc68..4a44f452 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomImageQueryParams.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomImageQueryParams.kt @@ -1,9 +1,9 @@ package com.prof18.rssparser.atom -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserAtomImageQueryParams : BaseXmlParserTest( +class XmlParserAtomImageQueryParams : BaseParserTest( feedPath = "atom-image-query-params.xml", channelTitle = "9to5Google", channelLink = "https://9to5google.com/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomRepliesLink.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomRepliesLink.kt index 8338a5c4..f421cf3f 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomRepliesLink.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomRepliesLink.kt @@ -1,9 +1,9 @@ package com.prof18.rssparser.atom -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserAtomRepliesLink : BaseXmlParserTest( +class XmlParserAtomRepliesLink : BaseParserTest( feedPath = "atom-replies-link-example.xml", channelTitle = "9to5Linux", channelLink = "https://9to5linux.com/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomSelfLink.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomSelfLink.kt index 0c8ae6a7..c6c8f58c 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomSelfLink.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomSelfLink.kt @@ -1,8 +1,8 @@ package com.prof18.rssparser.atom -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserAtomSelfLink : BaseXmlParserTest( +class XmlParserAtomSelfLink : BaseParserTest( feedPath = "atom-self-link-example.xml", channelTitle = "Simon Willison's Weblog", channelLink = "http://simonwillison.net/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomTest.kt index 7bb41fe5..46785111 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/atom/XmlParserAtomTest.kt @@ -1,9 +1,9 @@ package com.prof18.rssparser.atom -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserAtomTest: BaseXmlParserTest( +class XmlParserAtomTest: BaseParserTest( feedPath = "feed-atom-test.xml", channelTitle = "The Verge - All Posts", channelLink = "https://www.theverge.com/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/json/JsonKibtyTownTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/json/JsonKibtyTownTest.kt new file mode 100644 index 00000000..8d7cdb34 --- /dev/null +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/json/JsonKibtyTownTest.kt @@ -0,0 +1,16 @@ +package com.prof18.rssparser.json + +import com.prof18.rssparser.BaseParserTest + +class JsonKibtyTownTest : BaseParserTest( + feedPath = "feed-kibty-town.json", + channelTitle = "xyzeva's blog", + channelLink = "https://kibty.town/", + channelDescription = "random thoughts and other stuff", + articleTitle = "gaining access to anyones browser without them even visiting a website", + articleLink = "https://kibty.town/blog/arc/", + articleContent = """ +

we start at the homepage of arc. where i first landed when i first heard of it. i snatched a download and started analysing, the first thing i realised was that arc requires an account to use, why do they require an account? + """.trimIndent(), + articlePubDate = "Sat, 07 Sep 2024 00:00:00 GMT", +) diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/json/JsonNprTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/json/JsonNprTest.kt new file mode 100644 index 00000000..93a71368 --- /dev/null +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/json/JsonNprTest.kt @@ -0,0 +1,27 @@ +package com.prof18.rssparser.json + +import com.prof18.rssparser.BaseParserTest +import com.prof18.rssparser.model.RssImage + +class JsonNprTest : BaseParserTest( + feedPath = "feed-npr-world.json", + channelTitle = "World", + channelLink = "https://www.npr.org/sections/world/?utm_medium=JSONFeed&utm_campaign=world", + channelDescription = "NPR world news, international art and culture, world business and financial markets, world economy, and global trends in health, science and technology. Subscribe to the World Story of the Day podcast and RSS feed.", + channelImage = RssImage( + title = null, + link = null, + description = null, + url = "https://media.npr.org/images/stations/nprone_logos/npr.png" + ), + articleTitle = "Eggs and Bananas: Life after a Russian prison", + articleLink = "https://www.npr.org/2024/08/26/1198913145/eggs-and-bananas-life-after-a-russian-prison?utm_medium=JSONFeed&utm_campaign=world", + articleDescription = """ + It's been more than three weeks since the U.S. and Russia completed the largest prisoner swap since the collapse of the Soviet Union.Speaking from the White House shortly after news broke that three American prisoners were headed home, President Biden described the release as an "incredible relief."Russian-American journalist Alsu Kurmasheva was one of those prisoners, and she's sharing what life was like in a Russian prison and how she's adjusting to life at home. For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.Email us at considerthis@npr.org. + """.trimIndent(), + articleContent = """ +

It's been more than three weeks since the U.S. and Russia completed the largest prisoner swap since the collapse of the Soviet Union.

Speaking from the White House shortly after news broke that three American prisoners were headed home, President Biden described the release as an "incredible relief."

Russian-American journalist Alsu Kurmasheva was one of those prisoners, and she's sharing what life was like in a Russian prison and how she's adjusting to life at home.

For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.

Email us at considerthis@npr.org.

+ """.trimIndent(), + articlePubDate = "2024-08-26T20:32:01-04:00", + articleImage = "https://media.npr.org/assets/img/2024/08/26/gettyimages-2164266488-3743ec7bca7a3dfe2a1d959d7913440c39d73824.jpg" +) diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rdf/XmlParserRdfDistroWatchTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rdf/XmlParserRdfDistroWatchTest.kt index 73eaada1..20baeda6 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rdf/XmlParserRdfDistroWatchTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rdf/XmlParserRdfDistroWatchTest.kt @@ -1,9 +1,9 @@ package com.prof18.rssparser.rdf -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserRdfDistroWatchTest : BaseXmlParserTest( +class XmlParserRdfDistroWatchTest : BaseParserTest( feedPath = "feed-rdf-distrowatch.xml", channelTitle = "DistroWatch.com: DistroWatch Weekly", channelLink = "https://distrowatch.com/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rdf/XmlParserRdfTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rdf/XmlParserRdfTest.kt index ef5205e6..f98e9d3b 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rdf/XmlParserRdfTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rdf/XmlParserRdfTest.kt @@ -1,9 +1,9 @@ package com.prof18.rssparser.rdf -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserAtomTest: BaseXmlParserTest( +class XmlParserAtomTest: BaseParserTest( feedPath = "feed-rdf-test.xml", channelTitle = "Slashdot", channelLink = "https://slashdot.org/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserAudioFeedTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserAudioFeedTest.kt index c84b1a53..75419f37 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserAudioFeedTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserAudioFeedTest.kt @@ -17,13 +17,13 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage import com.prof18.rssparser.model.ItunesItemData import com.prof18.rssparser.model.ItunesChannelData import com.prof18.rssparser.model.ItunesOwner -class XmlParserAudioFeedTest : BaseXmlParserTest( +class XmlParserAudioFeedTest : BaseParserTest( feedPath = "feed-test-audio.xml", channelTitle = "Stuff You Should Know", channelLink = "https://www.howstuffworks.com", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserBingFeedImage.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserBingFeedImage.kt index 556227c0..c88ecaaa 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserBingFeedImage.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserBingFeedImage.kt @@ -1,9 +1,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserBingFeedImage : BaseXmlParserTest( +class XmlParserBingFeedImage : BaseParserTest( feedPath = "feed-bing-image.xml", channelTitle = "madrid - BingNews", channelLink = "https://www.bing.com:443/news/search?q=madrid&format=rss", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserCharEscape.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserCharEscape.kt index 601c702f..4fa0b634 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserCharEscape.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserCharEscape.kt @@ -1,9 +1,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserCharEscape : BaseXmlParserTest( +class XmlParserCharEscape : BaseParserTest( feedPath = "feed-char-escape.xml", channelTitle = "NYT > Health", channelLink = "https://www.nytimes.com/section/health", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserCharsetFeedTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserCharsetFeedTest.kt index 4bfea642..2dfcfb7f 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserCharsetFeedTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserCharsetFeedTest.kt @@ -17,13 +17,13 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage import com.prof18.rssparser.model.ItunesItemData import com.prof18.rssparser.model.ItunesChannelData import com.prof18.rssparser.model.ItunesOwner -class XmlParserCharsetFeedTest : BaseXmlParserTest( +class XmlParserCharsetFeedTest : BaseParserTest( feedPath = "feed-test-charset.xml", channelTitle = "Lørdagsrådet", channelLink = "https://radio.nrk.no/podkast/loerdagsraadet", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserCommentsTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserCommentsTest.kt index 7866021a..fa2798cd 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserCommentsTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserCommentsTest.kt @@ -17,9 +17,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserCommentsTest : BaseXmlParserTest( +class XmlParserCommentsTest : BaseParserTest( feedPath = "feed-comment.xml", channelTitle = "Hacker News", channelLink = "https://news.ycombinator.com/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserFeedRuTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserFeedRuTest.kt index 4135141a..01641ac9 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserFeedRuTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserFeedRuTest.kt @@ -17,9 +17,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserFeedRuTest : BaseXmlParserTest( +class XmlParserFeedRuTest : BaseParserTest( feedPath = "feed-test-ru.xml", channelTitle = "Аргументы и Факты", channelLink = "http://www.aif.ru/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserFeedThumbTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserFeedThumbTest.kt index 71a8c813..f3e6b9ba 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserFeedThumbTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserFeedThumbTest.kt @@ -1,9 +1,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserFeedThumbTest : BaseXmlParserTest( +class XmlParserFeedThumbTest : BaseParserTest( feedPath = "feed-test-thumb.xml", channelTitle = "HDblog.it", channelLink = "https://www.hdblog.it", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserGreekTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserGreekTest.kt index 347fa508..7061f132 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserGreekTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserGreekTest.kt @@ -1,8 +1,8 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserGreekTest : BaseXmlParserTest( +class XmlParserGreekTest : BaseParserTest( feedPath = "feed-test-greek.xml", channelTitle = "Liberal - ΕΠΙΚΑΙΡΟΤΗΤΑ", channelLink = "https://www.liberal.gr/news", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserHindiChannelImageTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserHindiChannelImageTest.kt index d4d42af1..4b8cc15c 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserHindiChannelImageTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserHindiChannelImageTest.kt @@ -1,9 +1,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserHindiChannelImageTest : BaseXmlParserTest( +class XmlParserHindiChannelImageTest : BaseParserTest( feedPath = "feed-test-hindi-channel-image.xml", channelTitle = "Latest News चीन News18 हिंदी", channelLink = "https://hindi.news18.com/rss/khabar/world/china.xml", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImage2FeedTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImage2FeedTest.kt index fcd272b4..50050ae0 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImage2FeedTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImage2FeedTest.kt @@ -17,10 +17,10 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserImage2FeedTest : BaseXmlParserTest( +class XmlParserImage2FeedTest : BaseParserTest( feedPath = "feed-test-image-2.xml", channelTitle = "F.C. Barcelona", channelLink = "https://www.mundodeportivo.com/futbol/fc-barcelona", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageChannelReverseTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageChannelReverseTest.kt index 0760e76d..bfb7ae5d 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageChannelReverseTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageChannelReverseTest.kt @@ -1,12 +1,12 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage import com.prof18.rssparser.model.ItunesItemData import com.prof18.rssparser.model.ItunesChannelData import com.prof18.rssparser.model.ItunesOwner -class XmlParserImageChannelReverseTest : BaseXmlParserTest( +class XmlParserImageChannelReverseTest : BaseParserTest( feedPath = "feed-test-image-channel-reverse.xml", channelTitle = "The Joe Rogan Experience", channelLink = "https://www.joerogan.com", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageEmptyTag.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageEmptyTag.kt index 649b1de0..55a0dd25 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageEmptyTag.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageEmptyTag.kt @@ -17,10 +17,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest -import com.prof18.rssparser.model.RssImage +import com.prof18.rssparser.BaseParserTest -class XmlParserImageEmptyTag : BaseXmlParserTest( +class XmlParserImageEmptyTag : BaseParserTest( feedPath = "feed-test-image-empty-tag.xml", channelTitle = "Hacker Noon", channelLink = "https://hackernoon.com", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageEnclosure.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageEnclosure.kt index e8b0010a..43b457b9 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageEnclosure.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageEnclosure.kt @@ -1,9 +1,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserImageEnclosure : BaseXmlParserTest( +class XmlParserImageEnclosure : BaseParserTest( feedPath = "feed-image-enclosure.xml", channelTitle = "Centrum dopravního výzkumu, v. v. i. (RSS 2.0)", channelLink = "https://www.cdv.cz/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageFeedTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageFeedTest.kt index dbd0c816..266a1efe 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageFeedTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageFeedTest.kt @@ -17,10 +17,10 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserImageFeedTest : BaseXmlParserTest( +class XmlParserImageFeedTest : BaseParserTest( feedPath = "feed-test-image.xml", channelTitle = "Movie Reviews", channelLink = "https://movieweb.com/movie-reviews/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageLinkTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageLinkTest.kt index 4d4dcaa3..0409629e 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageLinkTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserImageLinkTest.kt @@ -17,9 +17,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserImageLinkTest : BaseXmlParserTest( +class XmlParserImageLinkTest : BaseParserTest( feedPath = "feed-test-image-link.xml", channelTitle = "Bleacher Report - Front Page", channelLink = "https://bleacherreport.com", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserItemChannelImageTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserItemChannelImageTest.kt index 68684fb6..b9360d72 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserItemChannelImageTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserItemChannelImageTest.kt @@ -17,10 +17,10 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserItemChannelImageTest : BaseXmlParserTest( +class XmlParserItemChannelImageTest : BaseParserTest( feedPath = "feed-item-channel-image.xml", channelTitle = "www.espn.com - TOP", channelLink = "https://www.espn.com", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserItunesFeedTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserItunesFeedTest.kt index 65906a8d..c727ae61 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserItunesFeedTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserItunesFeedTest.kt @@ -1,12 +1,12 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage import com.prof18.rssparser.model.ItunesItemData import com.prof18.rssparser.model.ItunesChannelData import com.prof18.rssparser.model.ItunesOwner -class XmlParserItunesFeedTest : BaseXmlParserTest( +class XmlParserItunesFeedTest : BaseParserTest( feedPath = "feed-itunes.xml", channelTitle = "The Joe Rogan Experience", channelLink = "https://www.joerogan.com", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserItunesSeasonFeedTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserItunesSeasonFeedTest.kt index 0ce4df37..5ce2b98e 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserItunesSeasonFeedTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserItunesSeasonFeedTest.kt @@ -1,12 +1,12 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage import com.prof18.rssparser.model.ItunesItemData import com.prof18.rssparser.model.ItunesChannelData import com.prof18.rssparser.model.ItunesOwner -class XmlParserItunesSeasonFeedTest : BaseXmlParserTest( +class XmlParserItunesSeasonFeedTest : BaseParserTest( feedPath = "feed-itunes-season.xml", channelTitle = "With Gourley And Rust", channelLink = "https://www.patreon.com/withgourleyandrust", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserMultipleImageAndVideo.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserMultipleImageAndVideo.kt index 1f50fbf3..a85158a1 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserMultipleImageAndVideo.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserMultipleImageAndVideo.kt @@ -1,8 +1,8 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserMultipleImageAndVideo : BaseXmlParserTest( +class XmlParserMultipleImageAndVideo : BaseParserTest( feedPath = "feed-test-multiple-image-and-video.xml", channelTitle = "Motor.ru", channelLink = "https://motor.ru", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserSourceTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserSourceTest.kt index f3d7e334..039ca250 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserSourceTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserSourceTest.kt @@ -17,9 +17,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserSourceTest : BaseXmlParserTest( +class XmlParserSourceTest : BaseParserTest( feedPath = "feed-test-source.xml", channelTitle = "À la une - Google Actualités", channelLink = "https://news.google.com/?hl=fr&gl=BE&ceid=BE:fr", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserStandardFeedTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserStandardFeedTest.kt index ccda532d..f7058b2b 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserStandardFeedTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserStandardFeedTest.kt @@ -17,9 +17,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserStandardFeedTest : BaseXmlParserTest( +class XmlParserStandardFeedTest : BaseParserTest( feedPath = "feed-test.xml", channelTitle = "Android Authority", channelLink = "https://www.androidauthority.com", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserTimeFeedTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserTimeFeedTest.kt index 47bb47cf..007577c0 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserTimeFeedTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserTimeFeedTest.kt @@ -17,9 +17,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserTimeFeedTest : BaseXmlParserTest( +class XmlParserTimeFeedTest : BaseParserTest( feedPath = "feed-test-time.xml", channelTitle = "Drug Recalls", channelLink = "http://www.fda.gov/about-fda/contact-fda/stay-informed/rss-feeds/drug-recalls/rss.xml", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserUnexpectedTokenTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserUnexpectedTokenTest.kt index c5a154db..4038875a 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserUnexpectedTokenTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserUnexpectedTokenTest.kt @@ -17,9 +17,9 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest -class XmlParserUnexpectedTokenTest : BaseXmlParserTest( +class XmlParserUnexpectedTokenTest : BaseParserTest( feedPath = "feed-test-unexpected-token.xml", channelTitle = "Wheels Off-Road & 4x4", channelLink = "https://www.wheels24.co.za/", diff --git a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserXSLFeedTest.kt b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserXSLFeedTest.kt index 003e5c61..6d48f4c2 100644 --- a/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserXSLFeedTest.kt +++ b/rssparser/src/test/kotlin/com/prof18/rssparser/rss/XmlParserXSLFeedTest.kt @@ -17,10 +17,10 @@ package com.prof18.rssparser.rss -import com.prof18.rssparser.BaseXmlParserTest +import com.prof18.rssparser.BaseParserTest import com.prof18.rssparser.model.RssImage -class XmlParserXSLFeedTest : BaseXmlParserTest( +class XmlParserXSLFeedTest : BaseParserTest( feedPath = "feed-test-xsl.xml", channelTitle = "SkySports | Liverpool", channelLink = "http://www.skysports.com", diff --git a/rssparser/src/test/resources/feed-kibty-town.json b/rssparser/src/test/resources/feed-kibty-town.json new file mode 100644 index 00000000..fc59bbc5 --- /dev/null +++ b/rssparser/src/test/resources/feed-kibty-town.json @@ -0,0 +1,51 @@ +{ + "version": "https://jsonfeed.org/version/1", + "title": "xyzeva's blog", + "home_page_url": "https://kibty.town/", + "feed_url": "https://kibty.town/blog.json", + "description": "random thoughts and other stuff", + "items": [ + { + "id": "https://kibty.town/blog/arc/", + "url": "https://kibty.town/blog/arc/", + "title": "gaining access to anyones browser without them even visiting a website", + "content_html": "

we start at the homepage of arc. where i first landed when i first heard of it. i snatched a download and started analysing, the first thing i realised was that arc requires an account to use, why do they require an account?", + "date_published": "Sat, 07 Sep 2024 00:00:00 GMT" + }, + { + "id": "https://kibty.town/blog/a16z/", + "url": "https://kibty.town/blog/a16z/", + "title": "how to pwn a billion dollar vc firm using inspect element", + "content_html": "

background

\n

i like to do this thing where i search twitter, looking for companies, and then try giving them a quick pentest. i've done a lot of my hacks this way and its more effective than you think it is.

\n

on this search, i use the "Relevant People" tab more often than you think, this is how i got to a16z

\n
    \n
  • crypto bullshit -> venture capital firms for crypto -> a16z crypto -> a16z
  • \n
\n

the hack

\n

while looking into a16z, i did a usual subdomain scan and used tooling from lunchcat which does common checks on domains, scanning for secrets in js files, etc.

\n

in this search, i came across portfolio.a16z.com, a site that seems like a portfolio management tool for companies that are in a16z. while doing cursory checks like i usually do, lunchcat seemed to catch a AWS key referenced somewhere in the website.

\n

i confirmed this and what i saw in the js, was this.

\n
{\n    MARKETPLACE_URL: "<REDACTED>",\n    DATABASE_URL: "<REDACTED>",\n    SALESFORCE_CLIENT_ID: "<REDACTED>",\n    SALESFORCE_SECURITY_TOKEN: "<REDACTED>",\n    npm_config_user_agent: "<REDACTED>",\n    SALESFORCE_CLIENT_SECRET: "<REDACTED>",\n    SALESFORCE_USERNAME: "<REDACTED>",\n    OKTA_CLIENT_ID: "<REDACTED>",\n    OKTA_CLIENT_SECRET: "<REDACTED>",\n    SESSION_SECRET: "<REDACTED>",\n    API_USERNAME: "<REDACTED>",\n    GOOGLE_CLIENT_ID_DEVELOPMENT: "<REDACTED>",\n    CLIENT_TOKEN_SECRET: "<REDACTED>",\n    GOOGLE_CLIENT_SECRET_DEVELOPMENT: "<REDACTED>",\n    AWS_BUCKET_NAME: "<REDACTED>",\n    npm_config_prefix: "<REDACTED>",\n    REACT_APP_SENTRY_DSN: "<REDACTED>",\n    AWS_BUCKET_TEAM_PAGES: "<REDACTED>",\n    MAILGUN_API_KEY: "<REDACTED>",\n    GOOGLE_CLIENT_ID: "<REDACTED>",\n    AWS_LOGO_BUCKET_URL: "<REDACTED>",\n    SALESFORCE_KEY: "<REDACTED>",\n    GOOGLE_CLIENT_SECRET: "<REDACTED>",\n    PAPERTRAIL_API_TOKEN: "<REDACTED>",\n    MAILGUN_PASSWORD: "<REDACTED>",\n    OKTA_CALLBACK_URL: "<REDACTED>",\n    SALESFORCE_PASSWORD: "<REDACTED>",\n    MAILGUN_USER: "<REDACTED>",\n    AWS_ACCESS_KEY_ID: "<REDACTED>",\n    PNPM_CONFIG_CACHE: "<REDACTED>",\n    AWS_SECRET_ACCESS_KEY: "<REDACTED>",\n    MAILGUN_DOMAIN: "<REDACTED>",\n    GOOGLE_CALLBACK_URL_DEVELOPMENT: "<REDACTED>",\n    API_PASSWORD: "<REDACTED>",\n    SENTRY_DSN: "<REDACTED>",\n    SALESFORCE_LOGIN_URL: "<REDACTED>",\n    COOKIE_SECRET: "<REDACTED>",\n    OKTA_DOMAIN: "<REDACTED>",\n    NODE_MODULES_CACHE: "<REDACTED>",\n    GOOGLE_CALLBACK_URL: "<REDACTED>",,\n    NODE_ENV: "<REDACTED>",\n    HEROKU_POSTGRESQL_CRIMSON_URL: "<REDACTED>",\n    TALENTPLACE_URL: "<REDACTED>",\n}\n
\n

this was. horrifying, it was the entire process.env of a heroku instance, in the JS. put in dynamically.

\n

i did a quick valid look of the credentials and they didnt seem like fake credentials. they. were. real. and all someone had to do find them was go to the sources tab of inspect element.

\n

impact

\n

the compromised list of services:

\n
    \n
  • their database (containing PII)
  • \n
  • their AWS
  • \n
  • their salesforce (never checked, account may be limited)
  • \n
  • mailgun (arbitrary emails from a16z domains, and also could read older emails)
  • \n
  • ... and probably more
  • \n
\n

reward

\n

a16z did not give me any bug bounty on this because of the fact i publicly reached out instead of trying to reach out privately. the only reason i did it this way was because:

\n
    \n
  • there was no available contact on their main site
  • \n
  • the email i could find engineering@a16z.com bounced my emails
  • \n
\n

so, i dunno. imo this is unfair.

\n

related

\n

techcrunch article (lorenzo reached out to me seeing my tweet trying to get in contact with them and wrote a piece!): https://techcrunch.com/2024/07/18/researcher-finds-flaw-in-a16z-website-that-exposed-some-company-data/

\n", + "date_published": "Sat, 20 Jul 2024 00:00:00 GMT" + }, + { + "id": "https://kibty.town/blog/chattr/", + "url": "https://kibty.town/blog/chattr/", + "title": "how we owned almost all of america's fast food chains", + "content_html": "

check out mrbruhs blogpost if you havent already

\n

so recently i was on call with some friends messing with some other stuff when we remembered the existence of a scanner we made for firebase and found https://chattr.ai and realised they used firebase, we didnt know much about firebase at the time so we simply tried to find a tool to see if it was vulnerable to something obvious and we found firepwn, which seemed nice for a GUI tool, so we simply entered the details of chattr's firebase.

\n

at first, we got permission denied for everything, so we thought it was safe. but then we tried registering a account with firebase manually, and once we signed in, we could see literally everything.

\n

we were in a call at the time, and our reaction to this was insane, as if we look at chattr's website, they manage hiring for a lot of fast food chains.

\n

\"Carousel

\n

going from bad to worse

\n

at this point, we had quite a lot of access but we wanted to see how bad this could get. so we kept searching, and after looking at the admin dashboard javascript for a while we found these firestore collections:

\n
    \n
  • dialog
  • \n
  • jobCategories
  • \n
  • questions
  • \n
  • candidates
  • \n
  • candidateJobs
  • \n
  • orgs
  • \n
  • orgs/{orgID}/candidateJobs
  • \n
  • orgs/{orgID}/conversations
  • \n
  • orgs/{orgID}/groups
  • \n
  • orgs/{orgID}/jobs
  • \n
  • orgs/{orgID}/locations
  • \n
  • orgs/{orgID}/notifications
  • \n
  • orgs/{orgID}/users
  • \n
\n

these leaked quite a lot but what we were most interested in was getting access to the admin dashboard or getting a admin account, we quickly found out all of the admin accounts are in the organization of 0 which seems to be the chattr organization. if we look at one of these users we'll see this:

\n
{\n    "createdDt": REDACTED,\n    "id": "REDACTED",\n    "phone": "",\n    "shouldRefresh": false,\n    "jobTitle": "",\n    "forceLogout": false,\n    "locations": [],\n    "combinedLocations": [],\n    "scheduleOptions": [],\n    "lastLoginDt": REDACTED,\n    "modifiedDt": REDACTED,\n    "status": "active",\n    "isDeleted": false,\n    "email": "REDACTED@chattr.ai",\n    "createdBy": {\n      "id": "REDACTED",\n      "email": "REDACTED@chattr.ai"\n    },\n    "providerId": "REDACTED",\n    "ghostOrg": "0",\n    "modifiedBySource": "api-middleware-user-activity",\n    "roles": [\n      "SuperAdmin"\n    ],\n    "groups": [],\n    "modifiedBy": null,\n    "orgId": "0",\n    "lastActivity": REDACTED,\n    "firstname": "REDACTED",\n    "lastname": "REDACTED",\n    "timezone": "America/New_York"\n  }\n\n
\n

so out of pure curiosity i tried to create a document with a random id and replaced the provider id with our fake user id copying this document, and out of nowhere it worked:

\n

\"Photo

\n

and the impact here is obvious:

\n

\"Photo

\n

... and more:

\n

\"Photo

\n

... and even more:

\n

\"Photo

\n

going from worse to catastrophic

\n

we soon realised from this admin dashboard, we could view conversations of candidates, phone numbers, profile pictures and more very powerful stuff from this admin panel, but while looking around on their app i randomly went to their actual user interface where i discovered something very interesting.

\n

there was a "ghost" mode where superadmins could access someone elses account and fully control them, this was where i discovered the fact that we could view billing info with this:

\n

\"Photo

\n

we could also hire people and do other stuff with the ghost mode.

\n

lets tldr

\n

so, that was one hell of a ride but the basic TLDR is that

\n

big hiring company got fully pwned by a really stupid vulnerability that couldve been prevented really easily, the following data was exposed:

\n
    \n
  • billing information
  • \n
  • plaintext passwords (only 6 or so accounts had this)
  • \n
  • phone numbers
  • \n
  • resumes
  • \n
  • emails
  • \n
  • full application conversation
  • \n
  • candidate notes
  • \n
  • profile pictures
  • \n
  • addresses
  • \n
  • all notifications
  • \n
  • company phone numbers
  • \n
  • pay info
  • \n
\n

credits

\n\n

we did not save any info other then the screenshots above and maybe a few more, we did make sure to get rid of extremely sensetive personal information.

\n", + "date_published": "Wed, 10 Jan 2024 00:00:00 GMT" + }, + { + "id": "https://kibty.town/blog/microsoft-pwnage/", + "url": "https://kibty.town/blog/microsoft-pwnage/", + "title": "how to pwn microsoft", + "content_html": "

This article has moved, please click here

", + "date_published": "Wed, 10 Jan 2024 00:00:00 GMT" + }, + { + "id": "https://kibty.town/blog/workers-rs/", + "url": "https://kibty.town/blog/workers-rs/", + "title": "how to use workers-rs (prime edition)", + "content_html": "

so, recently prime has been making doom in ascii and wants to make a web frontend for it, but because its over tcp, he cant.

the most scalable way of doing this is through cloudflare workers, and because cloudflare workers uses JS, prime doesnt want to use it.

luckily, cloudflare has a way of creating workers with rust, which prime loves for some reason. this article explains how to make a simple project with workers-rs and get started on a ws <-> tcp proxy

starting a cloudflare worker project with rust

this is thankfully very simple, you just have to cargo generate cloudflare/workers-rs, this will give you some base code to work with.

using websockets with workers-rs

this part isnt as easy because there is barely any documentation on how to do it, but there is some test code found in the repo to do it with.

oh god its tcp time

this is the part that i assumed was going to be really bad with rust WASM bindings, but its suprisingly simple, there is still no documentation but there is some example code which just gives it all here

putting it all together

so, it was basically as simple as combining those 2 examples together, i've made a github repository to show how i did this, and stress tested it with 10,000 clients, it works pretty well!

", + "date_published": "Wed, 10 Jan 2024 00:00:00 GMT" + }, + { + "id": "https://kibty.town/blog/gamersafer/", + "url": "https://kibty.town/blog/gamersafer/", + "title": "why client-side environment variables are a bad idea", + "content_html": "

at the time of writing this is all patched, dont come to me asking if it still works and how you can do it.

\n

so, recently i was looking into a microsoft partner called gamersafer which promotes facial recognition KYC to log into games (fun stuff) and also notably runs the official minecraft serverlist which is riddled with servers that are against mojangs own rules

\n

the start

\n

im in some cringy "minecraft server owner" or "minecraft influencer" discord server where people like to shit on mojang (for no real reason) on, and i saw this message chain on one:

\n

\"im

\n

this reminded me of the fact that gamersafer exists and that they are a microsoft partner, which got me to poke around their service!

\n

initial recon

\n

so, as i do i was just looking at their subdomains to see if theres anything interesting on them, and one stuck out: admin.gamersafer.com.

\n

opening the domain in a browser displayed a login prompt that looked a little like this:

\n

\"a

\n

upon opening firefox devtools, i discovered that this page actually had JS sourcemaps on, which was going to be useful for reversing stuff.

\n

the first thing i do is to try to look for api calls, and as i did so, something stuck out with the api client, it seemed to be referencing something called REACT_APP_AWS_ACCESS_KEY and REACT_APP_AWS_SECRET_KEY while i dont know about aws that much, this looked a little funky.

\n

it was supposed to be in the process.env but obviously we arent in node so that doesnt exist so i decided to just search for it in the non-sourcemapped js:

\n

\"the

\n

so, i cleaned this up and took a better look at it:

\n
{\n\t"NODE_ENV": "production",\n\t"PUBLIC_URL": "",\n\t"WDS_SOCKET_HOST": null,\n\t"WDS_SOCKET_PATH": null,\n\t"WDS_SOCKET_PORT": null,\n\t"FAST_REFRESH": true,\n\t"REACT_APP_API_HOST": "apiv2.gamersafer.com",\n\t"REACT_APP_AWS_SECRET_KEY": "redacted+redacted",\n\t"REACT_APP_CHECKOUT_API_URL": "https://redacted.execute-api.us-east-1.amazonaws.com/prod/",\n\t"REACT_APP_AWS_ACCESS_KEY": "redacted",\n\t"REACT_APP_AUTH_SECRET_KEY": "redacted"\n}\n
\n

chaos begins

\n

at first i thought that the AWS secret key being exposed was fine because it had very limited permissions, so to confirm that i quickly looked at the AWS rest api to see how it worked, the module that seemed the simplest and juiciest to me was IAM, and long story short, the account/key we had access to, had full administrator access:

\n
<Path>/</Path>\n<AttachedManagedPolicies>\n  <member>\n    <PolicyArn>arn:aws:iam::aws:policy/AmazonAPIGatewayAdministrator</PolicyArn>\n    <PolicyName>AmazonAPIGatewayAdministrator</PolicyName>\n  </member>\n  <member>\n    <PolicyArn>arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs</PolicyArn>\n    <PolicyName>AmazonAPIGatewayPushToCloudWatchLogs</PolicyName>\n  </member>\n  <member>\n    <PolicyArn>arn:aws:iam::aws:policy/AdministratorAccess</PolicyArn>\n    <PolicyName>AdministratorAccess</PolicyName>\n  </member>\n  <member>\n    <PolicyArn>arn:aws:iam::aws:policy/AmazonSESFullAccess</PolicyArn>\n    <PolicyName>AmazonSESFullAccess</PolicyName>\n  </member>\n  <member>\n    <PolicyArn>arn:aws:iam::aws:policy/AmazonSQSFullAccess</PolicyArn>\n    <PolicyName>AmazonSQSFullAccess</PolicyName>\n  </member>\n  <member>\n    <PolicyArn>arn:aws:iam::aws:policy/AmazonAPIGatewayInvokeFullAccess</PolicyArn>\n    <PolicyName>AmazonAPIGatewayInvokeFullAccess</PolicyName>\n  </member>\n  <member>\n    <PolicyArn>arn:aws:iam::aws:policy/AmazonS3FullAccess</PolicyArn>\n    <PolicyName>AmazonS3FullAccess</PolicyName>\n  </member>\n  <member>\n    <PolicyArn>arn:aws:iam::aws:policy/AWSLambda_FullAccess</PolicyArn>\n    <PolicyName>AWSLambda_FullAccess</PolicyName>\n  </member>\n  <member>\n    <PolicyArn>arn:aws:iam::aws:policy/service-role/AmazonS3ObjectLambdaExecutionRolePolicy</PolicyArn>\n    <PolicyName>AmazonS3ObjectLambdaExecutionRolePolicy</PolicyName>\n  </member>\n</AttachedManagedPolicies>\n<GroupList />\n<UserName>redacted</UserName>\n<Arn>arn:aws:iam::redacted:user/redacted</Arn>\n<UserId>redacted</UserId>\n<CreateDate>redatced</CreateDate>\n<Tags />\n
\n

which means that if i wanted to, i could do anything like exfiltrate all their data and delete everything.

\n

disclosure

\n

so first i tried the gamersafer.com contact form, which didnt seem like it did anything.

\n

then i tried to get some contacts in gamersafer with the help of some friends. i was eventually able to get in contact with TheMisterEpic, which had previously talked to gamersafer before and he quickly created a groupchat with the product manager, who quickly responded to my inquiry.

\n

from there, it was just taking down stuff and revoking the keys.

\n

they said thanks but there was no bounty given (kinda expected)

\n

lessons learned

\n

dont use client environment variables for secrets, please.

\n

this mistake couldve been avoided by delegating more things to serverside api calls from authorized clients.

\n

oh fuck here we go again

\n

it has been 72 hours since disclosure of this vulnerability to gamersafer, they have not disclosed the breach of all of their infrastructure to end users which (i believe) is highly illegal in the GDPR.

\n

i also found another vulnerability in findmcserver which allowed me to approve my own server, give myself badges and approving my own server.

\n

\"find

\n

while this isnt as critical, they found the server and i started getting spam logged out (lol), i soon recieved a DM from the CEO.

\n

i also got this email from the ceo saying they disclosed it (havent checked and it was literally past the disclosure time anyway, better then nothing)\n\"Dear

\n", + "date_published": "Sat, 23 Dec 2023 00:00:00 GMT" + } + ] +} diff --git a/rssparser/src/test/resources/feed-npr-world.json b/rssparser/src/test/resources/feed-npr-world.json new file mode 100644 index 00000000..950279b5 --- /dev/null +++ b/rssparser/src/test/resources/feed-npr-world.json @@ -0,0 +1,551 @@ +{ + "version": "https://jsonfeed.org/version/1", + "title": "World", + "home_page_url": "https://www.npr.org/sections/world/?utm_medium=JSONFeed&utm_campaign=world", + "feed_url": "https://feeds.npr.org/1004/feed.json", + "description": "NPR world news, international art and culture, world business and financial markets, world economy, and global trends in health, science and technology. Subscribe to the World Story of the Day podcast and RSS feed.", + "icon": "https://media.npr.org/images/stations/nprone_logos/npr.png", + "author": { + "name": "NPR", + "url": "https://www.npr.org?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/images/stations/nprone_logos/npr.png" + }, + "items": [ + { + "id": "1198913145", + "url": "https://www.npr.org/2024/08/26/1198913145/eggs-and-bananas-life-after-a-russian-prison?utm_medium=JSONFeed&utm_campaign=world", + "title": "Eggs and Bananas: Life after a Russian prison", + "content_html": "

It's been more than three weeks since the U.S. and Russia completed the largest prisoner swap since the collapse of the Soviet Union.

Speaking from the White House shortly after news broke that three American prisoners were headed home, President Biden described the release as an \"incredible relief.\"

Russian-American journalist Alsu Kurmasheva was one of those prisoners, and she's sharing what life was like in a Russian prison and how she's adjusting to life at home.

For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.

Email us at considerthis@npr.org.

", + "summary": "It's been more than three weeks since the U.S. and Russia completed the largest prisoner swap since the collapse of the Soviet Union.Speaking from the White House shortly after news broke that three American prisoners were headed home, President Biden described the release as an \"incredible relief.\"Russian-American journalist Alsu Kurmasheva was one of those prisoners, and she's sharing what life was like in a Russian prison and how she's adjusting to life at home. For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.Email us at considerthis@npr.org.", + "image": "https://media.npr.org/assets/img/2024/08/26/gettyimages-2164266488-3743ec7bca7a3dfe2a1d959d7913440c39d73824.jpg", + "date_published": "2024-08-26T20:32:01-04:00", + "date_modified": "2024-08-26T21:00:00-04:00", + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR1237029792.mp3?orgId=1&p=&e=1198913145&size=10820172&d=676&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 676 + } + ] + }, + { + "id": "1197961495", + "url": "https://www.npr.org/2024/08/23/1197961495/the-trade-fraud-detective?utm_medium=JSONFeed&utm_campaign=world", + "title": "The trade fraud detective", + "content_html": "

When David Rashid took over US autoparts maker Plews and Edelmann, the company was losing business to its Chinese rival, Qingdao Sunsong. Both companies make power steering hoses, but Sunsong was offering its hoses to retailers at a much lower price.

Then, in 2018, the Trump administration threw companies like Rashid's a lifeline, by announcing tariffs on a range of Chinese goods, including some autoparts. Rashid thought the tariffs would finally force Sunsong to raise its prices, but, somehow, the company never did.

It was a mystery. And it led Rashid to take on a new role – amateur trade fraud investigator. How could his competitor, Sunsong, absorb that 25% tax without changing its prices? And why had all of Sunsong's steering hoses stopped coming from China and started coming from Thailand?

On today's episode, the wide gulf between how tariffs work in theory... and how they actually work in practice. And David Rashid's quest to figure out what, if anything, he could do about it. It's a quest that will involve international detectives, forensic chemists, and a friendship founded on a shared love for hummus.

This episode was hosted by Keith Romer and Jeff Guo. It was produced by Emma Peaslee and edited by Molly Messick. It was fact-checked by Sierra Juarez and engineered by Ko Takasugi-Czernowin. Alex Goldmark is our executive producer.

Help support
Planet Money and hear our bonus episodes by subscribing to Planet Money+ in Apple Podcasts or at plus.npr.org/planetmoney.

", + "summary": "When David Rashid took over US autoparts maker Plews and Edelmann, the company was losing business to its Chinese rival, Qingdao Sunsong. Both companies make power steering hoses, but Sunsong was offering its hoses to retailers at a much lower price.Then, in 2018, the Trump administration threw companies like Rashid's a lifeline, by announcing tariffs on a range of Chinese goods, including some autoparts. Rashid thought the tariffs would finally force Sunsong to raise its prices, but, somehow, the company never did.It was a mystery. And it led Rashid to take on a new role – amateur trade fraud investigator. How could his competitor, Sunsong, absorb that 25% tax without changing its prices? And why had all of Sunsong's steering hoses stopped coming from China and started coming from Thailand?On today's episode, the wide gulf between how tariffs work in theory... and how they actually work in practice. And David Rashid's quest to figure out what, if anything, he could do about it. It's a quest that will involve international detectives, forensic chemists, and a friendship founded on a shared love for hummus.This episode was hosted by Keith Romer and Jeff Guo. It was produced by Emma Peaslee and edited by Molly Messick. It was fact-checked by Sierra Juarez and engineered by Ko Takasugi-Czernowin. Alex Goldmark is our executive producer.Help support Planet Money and hear our bonus episodes by subscribing to Planet Money+ in Apple Podcasts or at plus.npr.org/planetmoney.", + "image": "https://media.npr.org/assets/img/2024/08/23/the-hong-kong-based-cscl-east-china-sea-container-ship-sits-news-photo_wide-64420882948df3fb63eebd12b245a344aa2137ff.jpg", + "date_published": "2024-08-23T17:26:50-04:00", + "author": { + "name": "Keith Romer", + "url": "https://www.npr.org/people/906714600/keith-romer?utm_medium=JSONFeed&utm_campaign=world" + }, + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR4020779994.mp3?orgId=1&p=&e=1197961495&size=25659438&d=1603&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 1603 + } + ] + }, + { + "id": "1198913099", + "url": "https://www.npr.org/2024/08/20/1198913099/bangladeshs-student-protestors-are-now-helping-to-running-the-country?utm_medium=JSONFeed&utm_campaign=world", + "title": "Bangladesh's student protestors are now helping to run the country", + "content_html": "

Earlier this month, student protestors filled the streets of Dhaka, Bangladesh, in opposition to a controversial quota system for government jobs.

Authorities then cracked down on demonstrators, blocking internet access, imposing a curfew and issuing police officers a shoot-on-sight order. In just over a month, more than 600 people have been killed.

And as the protests escalated, the demonstrations started to become about much more than just the quota system.

Eventually, students were able to force Prime Minister Sheikh Hasina to resign.

The students who ousted Hasina are now helping to lead Bangladesh.

\"We youth are not only the generation of Facebook, YouTube and Instagram,\" says 19-year-old protestor Mumtahana Munir Mitti.

\"We also love our country. And we also love to participate in [the] rebuilding of our country.\"

For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.

Email us at considerthis@npr.org.

", + "summary": "Earlier this month, student protestors filled the streets of Dhaka, Bangladesh, in opposition to a controversial quota system for government jobs.Authorities then cracked down on demonstrators, blocking internet access, imposing a curfew and issuing police officers a shoot-on-sight order. In just over a month, more than 600 people have been killed. And as the protests escalated, the demonstrations started to become about much more than just the quota system.Eventually, students were able to force Prime Minister Sheikh Hasina to resign.The students who ousted Hasina are now helping to lead Bangladesh. \"We youth are not only the generation of Facebook, YouTube and Instagram,\" says 19-year-old protestor Mumtahana Munir Mitti. \"We also love our country. And we also love to participate in [the] rebuilding of our country.\"For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.Email us at considerthis@npr.org.", + "image": "https://media.npr.org/assets/img/2024/08/20/ap24226483537010-1--5ef68ed20f3ca9820a410ecfb381a9c65170dd12.jpg", + "date_published": "2024-08-20T17:16:01-04:00", + "date_modified": "2024-08-26T11:18:00-04:00", + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR3732664152.mp3?orgId=1&p=&e=1198913099&size=7556747&d=472&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 472 + } + ] + }, + { + "id": "1196983088", + "url": "https://www.npr.org/2024/08/16/1196983088/what-is-the-strategy-behind-ukraines-assault-into-russia?utm_medium=JSONFeed&utm_campaign=world", + "title": "What is the Strategy Behind Ukraine's Assault Into Russia?", + "content_html": "

Ukraine's attack into Russian territory surprised many. Including, it would seem, the Russians themselves. But what are the strategic goals Ukraine is hoping to achieve with this move? We hear from a retired U.S. lieutenant general.

", + "summary": "Ukraine's attack into Russian territory surprised many. Including, it would seem, the Russians themselves. But what are the strategic goals Ukraine is hoping to achieve with this move? We hear from a retired U.S. lieutenant general.", + "image": "https://media.npr.org/assets/img/2024/08/16/gettyimages-2166170798_pod-02f28a0ef38c39025ca149c9cd378410964e18de.jpg", + "date_published": "2024-08-16T14:38:39-04:00", + "date_modified": "2024-08-16T15:00:00-04:00", + "author": { + "name": "Michel Martin", + "url": "https://www.npr.org/people/5201175/michel-martin?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2014/10/31/martin_bio2_sq-aed91a2b1da7834d384a340be0eb939aaa0cca49.jpg" + }, + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR1130316053.mp3?orgId=1&p=&e=1196983088&size=5060739&d=316&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 316 + } + ] + }, + { + "id": "1196982966", + "url": "https://www.npr.org/2024/08/15/1196982966/youth-in-charge-in-bangladesh?utm_medium=JSONFeed&utm_campaign=world", + "title": "Youth in Charge in Bangladesh", + "content_html": "

Earlier this month a youth-led movement helped topple the government of an autocratic prime minister in Bangladesh. Now students sweep broken glass, direct traffic, and join the government. But can they rebuild a country? We go to the streets of Dhaka.

", + "summary": "Earlier this month a youth-led movement helped topple the government of an autocratic prime minister in Bangladesh. Now students sweep broken glass, direct traffic, and join the government. But can they rebuild a country? We go to the streets of Dhaka.", + "image": "https://media.npr.org/assets/img/2024/08/15/_mg_3009_pod1-20c0aed2f55a4d74327ec18bd6bc86de37de29fc.jpg", + "date_published": "2024-08-15T15:01:15-04:00", + "author": { + "name": "Diaa Hadid", + "url": "https://www.npr.org/people/536641200/diaa-hadid?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2023/10/17/Diaa-Hadid-headshot_vert-c1d8a41c2eee0f8f3ef479c3198d912ea53ab1b6.jpg" + }, + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR1874396556.mp3?orgId=1&p=&e=1196982966&size=7191913&d=449&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 449 + } + ] + }, + { + "id": "1196982947", + "url": "https://www.npr.org/2024/08/14/1196982947/the-opposition-in-venezuela-is-afraid?utm_medium=JSONFeed&utm_campaign=world", + "title": "The Opposition in Venezuela is Afraid", + "content_html": "

Following a disputed election in Venezuela, autocratic president Nicolàs Maduro is cracking down on the opposition. Thousands have been arrested and lawmakers are threatening social media sites and planning to close down civic groups. We hear from opponents of Maduro.

", + "summary": "Following a disputed election in Venezuela, autocratic president Nicolàs Maduro is cracking down on the opposition. Thousands have been arrested and lawmakers are threatening social media sites and planning to close down civic groups. We hear from opponents of Maduro.", + "image": "https://media.npr.org/assets/img/2024/08/14/gettyimages-2165139578_pod-501ee097b06fe62d4bcbb6c98feb322291bb82ea.jpg", + "date_published": "2024-08-14T18:13:41-04:00", + "author": { + "name": "Carrie Kahn", + "url": "https://www.npr.org/people/2100701/carrie-kahn?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2022/09/16/carriekahn_vert-4b2724e15f639a4ee755aac154177f2b014b2a68.jpg" + }, + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR9739130715.mp3?orgId=1&p=&e=1196982947&size=4787767&d=299&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 299 + } + ] + }, + { + "id": "1198913054", + "url": "https://www.npr.org/2024/08/14/1198913054/gaza-israel-middle-east-cease-fire-war?utm_medium=JSONFeed&utm_campaign=world", + "title": "A new Israel-Hamas cease-fire talk starts this week. Is anything different this time?", + "content_html": "

So often, telling the story of the Israel-Hamas war is reduced to a catalog of numbers.

But this war is much more than all of that. It is the daily life of the people living in the midst of the war that has now been raging for 10 months.

The war has also come to encompass a sense of insecurity that permeates, as the humanitarian crisis worsens in Gaza through famine, unclean water and dwindling resources. Pair that with the prospect of a wider regional conflict with Iran that looms nearby.

On Thursday, U.S. and Arab mediators will launch new talks to attempt a cease-fire deal between Israel and Hamas. But hopes for tensions to be diffused are not high.

For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.

Email us at considerthis@npr.org.

", + "summary": "So often, telling the story of the Israel-Hamas war is reduced to a catalog of numbers.But this war is much more than all of that. It is the daily life of the people living in the midst of the war that has now been raging for 10 months.The war has also come to encompass a sense of insecurity that permeates, as the humanitarian crisis worsens in Gaza through famine, unclean water and dwindling resources. Pair that with the prospect of a wider regional conflict with Iran that looms nearby.On Thursday, U.S. and Arab mediators will launch new talks to attempt a cease-fire deal between Israel and Hamas. But hopes for tensions to be diffused are not high.For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.Email us at considerthis@npr.org.", + "image": "https://media.npr.org/assets/img/2024/08/14/gettyimages-2166154020_custom-6d1bbe0bbd6bb20a7b8d22402ad540514b22b759.jpg", + "date_published": "2024-08-14T17:56:39-04:00", + "tags": [ + "war", + "Middle East", + "Israel", + "Gaza" + ], + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR6889271777.mp3?orgId=1&p=&e=1198913054&size=8459956&d=528&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 528 + } + ] + }, + { + "id": "1198913032", + "url": "https://www.npr.org/2024/08/09/1198913032/how-is-the-plot-to-attack-a-taylor-swift-concert-related-to-isis-k?utm_medium=JSONFeed&utm_campaign=world", + "title": "How is the plot to attack a Taylor Swift concert related to ISIS-K?", + "content_html": "

Three Taylor Swift concerts were canceled in Austria this week, after authorities foiled planned attacks on the venue.

Three young men are now in custody, and at least two of them recently pledged allegiance to the Islamic State — specifically an affiliate group known as ISIS-K.

This isn't the first time Islamic State-related groups have been tied to attacks in Europe — over 140 people were killed in an attack on a Moscow concert hall earlier this year, and an explosion at an Ariana Grande concert in 2017 killed 22 and injured more than a thousand.

So - what exactly is ISIS-K, and how should we think about their presence in Europe?

For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.

Email us at considerthis@npr.org.

", + "summary": "Three Taylor Swift concerts were canceled in Austria this week, after authorities foiled planned attacks on the venue. Three young men are now in custody, and at least two of them recently pledged allegiance to the Islamic State — specifically an affiliate group known as ISIS-K.This isn't the first time Islamic State-related groups have been tied to attacks in Europe — over 140 people were killed in an attack on a Moscow concert hall earlier this year, and an explosion at an Ariana Grande concert in 2017 killed 22 and injured more than a thousand.So - what exactly is ISIS-K, and how should we think about their presence in Europe?For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.Email us at considerthis@npr.org.", + "image": "https://media.npr.org/assets/img/2024/08/09/gettyimages-2165307527-3c9aa1e64d4b91f977a6b34227710f16f04923c8.jpg", + "date_published": "2024-08-09T18:15:37-04:00", + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR7910705302.mp3?orgId=1&p=&e=1198913032&size=9994702&d=624&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 624 + } + ] + }, + { + "id": "1196982856", + "url": "https://www.npr.org/2024/08/08/1196982856/the-root-cause-of-the-race-riots-in-the-uk?utm_medium=JSONFeed&utm_campaign=world", + "title": "The Root Cause of the Race Riots in the UK", + "content_html": "

For over a week race riots have broken out in the United Kingdom, mostly in smaller, poorer, less diverse cities. The violence has been stoked by misinformation and disinformation online. Our reporter travels to one of the places that saw mob violence to understand why it's happening.

", + "summary": "For over a week race riots have broken out in the United Kingdom, mostly in smaller, poorer, less diverse cities. The violence has been stoked by misinformation and disinformation online. Our reporter travels to one of the places that saw mob violence to understand why it's happening.", + "image": "https://media.npr.org/assets/img/2024/08/08/gettyimages-2164915111_pod-a8079cd843aa3d2e950222832f6ad277aef72b22.jpg", + "date_published": "2024-08-08T17:53:14-04:00", + "author": { + "name": "Lauren Frayer", + "url": "https://www.npr.org/people/463861805/lauren-frayer?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2022/05/24/lauren-frayer-headshot_vert-633a43936d9726bbf235ad789b42c7ee61eaaca9.jpg" + }, + "tags": [ + "United Kingdom" + ], + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR7070738016.mp3?orgId=1&p=&e=1196982856&size=5056933&d=316&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 316 + } + ] + }, + { + "id": "1197958294", + "url": "https://www.npr.org/2024/08/07/1197958294/the-beauty-and-entitlement-of-traveling-as-a-tourist?utm_medium=JSONFeed&utm_campaign=world", + "title": "The beauty and entitlement of traveling as a tourist", + "content_html": "

Summer is a time when many Americans are taking off from work and setting their sights on far-off vacation destinations: tropical beaches, fairy-tale cities, sun-drenched countrysides. But in her book Airplane Mode, the reluctant travel writer Shahnaz Habib warns of recklessly embracing what she calls \"passport privilege,\" — and how that can skew peoples' images of what the world is and who it belongs to.

", + "summary": "Summer is a time when many Americans are taking off from work and setting their sights on far-off vacation destinations: tropical beaches, fairy-tale cities, sun-drenched countrysides. But in her book Airplane Mode, the reluctant travel writer Shahnaz Habib warns of recklessly embracing what she calls \"passport privilege,\" — and how that can skew peoples' images of what the world is and who it belongs to.", + "date_published": "2024-08-07T03:00:00-04:00", + "author": { + "name": "B.A. Parker", + "url": "https://www.npr.org/people/1114056142/b-a-parker?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2022/09/20/ba_parker_4_vert-3d8b39307c2df35e48ca52c5ec2d229c7fbf43cc.jpg" + }, + "tags": [ + "passport", + "summer vacation", + "Travel" + ], + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR9589200205.mp3?orgId=1&p=&e=1197958294&size=31264270&d=1953&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 1953 + } + ] + }, + { + "id": "1196982837", + "url": "https://www.npr.org/2024/08/06/1196982837/protests-force-bangladeshs-longest-serving-prime-minister-to-flee?utm_medium=JSONFeed&utm_campaign=world", + "title": "Protests Force Bangladesh's Longest-Serving Prime Minister to Flee", + "content_html": "

This week the prime minister of Bangladesh fled the country by helicopter, forced out by a protest movement that started peacefully but became violent. We hear from our correspondent about the events that lead to this dramatic change in government. And we hear reaction from a journalist who had been jailed 6 years ago for criticizing that government.

", + "summary": "This week the prime minister of Bangladesh fled the country by helicopter, forced out by a protest movement that started peacefully but became violent. We hear from our correspondent about the events that lead to this dramatic change in government. And we hear reaction from a journalist who had been jailed 6 years ago for criticizing that government.", + "image": "https://media.npr.org/assets/img/2024/08/06/gettyimages-2164998903-abab1dfc2ac98ebf56a90fa027a120452928648e.jpg", + "date_published": "2024-08-06T17:09:51-04:00", + "date_modified": "2024-08-06T18:01:00-04:00", + "author": { + "name": "Diaa Hadid", + "url": "https://www.npr.org/people/536641200/diaa-hadid?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2023/10/17/Diaa-Hadid-headshot_vert-c1d8a41c2eee0f8f3ef479c3198d912ea53ab1b6.jpg" + }, + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR7737736403.mp3?orgId=1&p=&e=1196982837&size=8503424&d=531&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 531 + } + ] + }, + { + "id": "1198913002", + "url": "https://www.npr.org/2024/08/05/1198913002/consider-this-from-npr-draft-08-05-2024?utm_medium=JSONFeed&utm_campaign=world", + "title": "They are Olympic athletes — and refugees", + "content_html": "

There are some 43 million refugees in the world, according to the U.N.'s refugee agency.

The 37 of them competing in Paris as the Refugee Olympic Team are fighting for something more than just athletic excellence.

We hear from judoka Muna Dahouk and kayaker Saman Soltani.

For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.

Email us at considerthis@npr.org.

", + "summary": "There are some 43 million refugees in the world, according to the U.N.'s refugee agency.The 37 of them competing in Paris as the Refugee Olympic Team are fighting for something more than just athletic excellence.We hear from judoka Muna Dahouk and kayaker Saman Soltani.For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.Email us at considerthis@npr.org.", + "image": "https://media.npr.org/assets/img/2024/08/05/gettyimages-2163013956_wide-81d6e96313fe790ef8deaf11d7385f1d02339f48.jpg", + "date_published": "2024-08-05T16:32:16-04:00", + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR8639809213.mp3?orgId=1&p=&e=1198913002&size=7662490&d=478&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 478 + } + ] + }, + { + "id": "1200150223", + "url": "https://www.npr.org/2026/01/01/1200150223/tested-lumpers-splitters?utm_medium=JSONFeed&utm_campaign=world", + "title": "Tested: Lumpers and Splitters", + "content_html": "

Episode 6: Christine and Max are some of the most recent female athletes in this century-long history to face tests, stigma, and restrictions. But they are unlikely to be the last. In this episode, we find out whether Christine qualifies for the Paris Olympics, as well as the fate of Max's court case. And we explore the broader implications of the sex binary in sports. Is there a better way for sports to be categorized?

To listen to this series sponsor-free and support NPR, sign up for Embedded+ in Apple Podcasts or at plus.npr.org.

", + "summary": "Episode 6: Christine and Max are some of the most recent female athletes in this century-long history to face tests, stigma, and restrictions. But they are unlikely to be the last. In this episode, we find out whether Christine qualifies for the Paris Olympics, as well as the fate of Max's court case. And we explore the broader implications of the sex binary in sports. Is there a better way for sports to be categorized? To listen to this series sponsor-free and support NPR, sign up for Embedded+ in Apple Podcasts or at plus.npr.org.", + "image": "https://media.npr.org/assets/img/2024/07/26/tested_ep6_final_slide-48bd01e57d5dc778463a954759459fc9060788c9.jpg", + "date_published": "2024-08-01T03:00:00-04:00", + "date_modified": "2024-08-01T10:08:00-04:00", + "author": { + "name": "Rose Eveleth", + "url": "https://www.npr.org/people/1255807297/rose-eveleth?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2024/07/17/rose-eveleth_vert-56847eb19405581fa8b8b324aa4a80fb0d824bd8.jpg" + }, + "tags": [ + "women's sports", + "Paris Olympics 2024", + "dsd athletes", + "track and field", + "testosterone", + "gender" + ], + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR8395846056.mp3?orgId=1&p=&e=1200150223&size=41165723&d=2572&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 2572 + } + ] + }, + { + "id": "1198912985", + "url": "https://www.npr.org/2024/07/31/1198912985/two-assassinations-of-major-leaders-could-change-the-middle-east?utm_medium=JSONFeed&utm_campaign=world", + "title": "Two assassinations of major leaders could change the Middle East", + "content_html": "

In the Middle East, two assassinations in less than 24 hours could transform the region. Israel claimed responsibility for one. It has no comment on the other.

First, an Israeli attack in Lebanon killed a leader of the militant group Hezbollah. Just hours later, the political leader of Hamas was killed in Iran.

The Hamas leader Ismail Haniyeh was attending the swearing-in for Iran's new reformist president. Hamas says Haniyeh was killed by a rocket fired into his room at an official residency. Hamas and Iran both blame Israel for the attack.

When Israeli Prime Minister Benjamin Netanyahu spoke after the two killings, he did not claim responsibility for the attack in Tehran. He did describe the Israeli strike in Beirut as a crushing blow.

In Washington, White House spokesman John Kirby expressed concern the assassinations could result in an escalation of the conflicts already playing out.

Two assassinations in the Middle East have the potential to start a violent chain of retaliations. Will they?

For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.

Email us at considerthis@npr.org.

", + "summary": "In the Middle East, two assassinations in less than 24 hours could transform the region. Israel claimed responsibility for one. It has no comment on the other. First, an Israeli attack in Lebanon killed a leader of the militant group Hezbollah. Just hours later, the political leader of Hamas was killed in Iran. The Hamas leader Ismail Haniyeh was attending the swearing-in for Iran's new reformist president. Hamas says Haniyeh was killed by a rocket fired into his room at an official residency. Hamas and Iran both blame Israel for the attack.When Israeli Prime Minister Benjamin Netanyahu spoke after the two killings, he did not claim responsibility for the attack in Tehran. He did describe the Israeli strike in Beirut as a crushing blow. In Washington, White House spokesman John Kirby expressed concern the assassinations could result in an escalation of the conflicts already playing out.Two assassinations in the Middle East have the potential to start a violent chain of retaliations. Will they?For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.Email us at considerthis@npr.org.", + "image": "https://media.npr.org/assets/img/2024/07/31/gettyimages-2164035472-4d5900cb4e388182ecea9daffac13f9b3afe4869.jpg", + "date_published": "2024-07-31T18:07:29-04:00", + "date_modified": "2024-07-31T19:03:00-04:00", + "tags": [ + "Hezbollah", + "Hamas", + "Israel" + ], + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR4402556037.mp3?orgId=1&p=&e=1198912985&size=8833194&d=552&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 552 + } + ] + }, + { + "id": "1198912978", + "url": "https://www.npr.org/2024/07/29/1198912978/venezuelans-foresaw-a-new-chapter-then-maduro-claimed-victory?utm_medium=JSONFeed&utm_campaign=world", + "title": "Venezuelans foresaw a new chapter. Then Maduro claimed victory", + "content_html": "

For a brief moment, people in the Venezuelan diaspora felt a surge of hope as reports indicated the opposition party was polling way ahead of Nicolas Maduro's party. Then, Venezuela's electoral authority declared Maduro the winner.

Members of the opposition have cried foul. And the US and other international observers have questioned the integrity of the election.

So where does Sunday's election leave Venezuelans, who are living in the midst of a humanitarian emergency?

And where does it leave the nearly 8 million people who have left Venezuela during President Maduro's time in office?

For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.

Email us at considerthis@npr.org.

", + "summary": "For a brief moment, people in the Venezuelan diaspora felt a surge of hope as reports indicated the opposition party was polling way ahead of Nicolas Maduro's party. Then, Venezuela's electoral authority declared Maduro the winner. Members of the opposition have cried foul. And the US and other international observers have questioned the integrity of the election.So where does Sunday's election leave Venezuelans, who are living in the midst of a humanitarian emergency? And where does it leave the nearly 8 million people who have left Venezuela during President Maduro's time in office?For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.Email us at considerthis@npr.org.", + "image": "https://media.npr.org/assets/img/2024/07/29/gettyimages-2163819375_wide-666742d8881d887dbefc4c4d13f41afa5c035046.jpg", + "date_published": "2024-07-29T18:12:46-04:00", + "date_modified": "2024-07-29T18:19:00-04:00", + "tags": [ + "Venezuela" + ], + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR6360706730.mp3?orgId=1&p=&e=1198912978&size=9843819&d=615&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 615 + } + ] + }, + { + "id": "1200150217", + "url": "https://www.npr.org/2026/01/01/1200150217/tested-unfair-advantage?utm_medium=JSONFeed&utm_campaign=world", + "title": "Tested: Unfair Advantage?", + "content_html": "

Episode 5: A battle over science and ethics unfolds. World Athletics releases and then tweaks multiple policies impacting DSD athletes, while critics cry foul. In this episode, World Athletics doubles down on its claims, Caster Semenya challenges the rules again, and we dig deep on a big question: what constitutes an \"unfair\" advantage on the track?

To listen to this series sponsor-free and support NPR, sign up for Embedded+ in Apple Podcasts or at plus.npr.org.

", + "summary": "Episode 5: A battle over science and ethics unfolds. World Athletics releases and then tweaks multiple policies impacting DSD athletes, while critics cry foul. In this episode, World Athletics doubles down on its claims, Caster Semenya challenges the rules again, and we dig deep on a big question: what constitutes an \"unfair\" advantage on the track? To listen to this series sponsor-free and support NPR, sign up for Embedded+ in Apple Podcasts or at plus.npr.org.", + "image": "https://media.npr.org/assets/img/2024/07/24/tested_ep5_finalv2_custom-aa62e06daae7a1c295d03bdb71d50101adf813d2.jpg", + "date_published": "2024-07-29T05:30:00-04:00", + "date_modified": "2024-07-29T05:45:00-04:00", + "author": { + "name": "Rose Eveleth", + "url": "https://www.npr.org/people/1255807297/rose-eveleth?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2024/07/17/rose-eveleth_vert-56847eb19405581fa8b8b324aa4a80fb0d824bd8.jpg" + }, + "tags": [ + "women's sports", + "World Athletics", + "Paris Olympics 2024", + "dsd athletes", + "Caster Semenya", + "testosterone", + "gender" + ], + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR3315077975.mp3?orgId=1&p=&e=1200150217&size=34505126&d=2156&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 2156 + } + ] + }, + { + "id": "1198912962", + "url": "https://www.npr.org/2024/07/25/1198912962/these-team-usa-marathon-runners-are-rooting-for-each-other-on-and-off-the-track?utm_medium=JSONFeed&utm_campaign=world", + "title": "These team USA marathon runners are rooting for each other on and off the track", + "content_html": "

Clayton Young and Conner Mantz are longtime training partners and friends. They're also the two fastest men's marathoners representing the U.S. at the 2024 Summer Olympics in Paris.

The pair met on a run at Brigham Young University in 2017. They've been friends, training partners and competitors ever since.

With years of friendship and thousands of miles binding them together, can Young and Mantz break away from the pack and take home the gold at the Olympic games?

For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.

Email us at considerthis@npr.org.

", + "summary": "Clayton Young and Conner Mantz are longtime training partners and friends. They're also the two fastest men's marathoners representing the U.S. at the 2024 Summer Olympics in Paris.The pair met on a run at Brigham Young University in 2017. They've been friends, training partners and competitors ever since. With years of friendship and thousands of miles binding them together, can Young and Mantz break away from the pack and take home the gold at the Olympic games?For sponsor-free episodes of Consider This, sign up for Consider This+ via Apple Podcasts or at plus.npr.org.Email us at considerthis@npr.org.", + "image": "https://media.npr.org/assets/img/2024/07/25/gettyimages-1983011244-45659d7d7fb80ed9e2d8e1beebd1af906b28c8dc.jpg", + "date_published": "2024-07-25T17:54:25-04:00", + "tags": [ + "Paris Olympics 2024" + ], + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR5408391973.mp3?orgId=1&p=&e=1198912962&size=8398934&d=524&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 524 + } + ] + }, + { + "id": "1216507712", + "url": "https://www.npr.org/2026/01/01/1216507712/tested-running-in-circles?utm_medium=JSONFeed&utm_campaign=world", + "title": "Tested: Running in Circles", + "content_html": "

Episode 4: In 2009, South African sprinter Caster Semenya won gold at the World Championships. But instead of a celebration, she endured endless speculation about her body, her biology, and her gender. And soon, sports organizations would launch a new round of regulations, lead to multiple court cases, and require sporting organizations to justify their claim that DSD athletes have an unfair advantage.

", + "summary": "Episode 4: In 2009, South African sprinter Caster Semenya won gold at the World Championships. But instead of a celebration, she endured endless speculation about her body, her biology, and her gender. And soon, sports organizations would launch a new round of regulations, lead to multiple court cases, and require sporting organizations to justify their claim that DSD athletes have an unfair advantage.", + "image": "https://media.npr.org/assets/img/2024/07/19/tested_ep4_v2_custom-188230070a0220dba4e529a1de9f55202abc26ed.jpg", + "date_published": "2024-07-25T03:00:00-04:00", + "date_modified": "2024-07-25T10:24:00-04:00", + "author": { + "name": "Rose Eveleth", + "url": "https://www.npr.org/people/1255807297/rose-eveleth?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2024/07/17/rose-eveleth_vert-56847eb19405581fa8b8b324aa4a80fb0d824bd8.jpg" + }, + "tags": [ + "women's sports", + "World Athletics", + "Paris Olympics 2024", + "dsd athletes", + "track and field", + "testosterone", + "gender" + ], + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR9943382766.mp3?orgId=1&p=&e=1216507712&size=31101684&d=1943&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 1943 + } + ] + }, + { + "id": "1216507659", + "url": "https://www.npr.org/2026/01/01/1216507659/tested-card-carrying-females?utm_medium=JSONFeed&utm_campaign=world", + "title": "Tested: Card-Carrying Females", + "content_html": "

Episode 3: We meet Kenyan sprinter Maximila Imali, who—like Christine Mboma—has been sidelined by DSD policies. She makes a different choice from Christine: to fight the regulations in court. And we learn about a previous fight, when scientists, athletes, and journalists spent thirty years trying to end an earlier version of sex testing.

To listen to this series sponsor-free and support NPR, sign up for Embedded+ in Apple Podcasts or at plus.npr.org.

", + "summary": "Episode 3: We meet Kenyan sprinter Maximila Imali, who—like Christine Mboma—has been sidelined by DSD policies. She makes a different choice from Christine: to fight the regulations in court. And we learn about a previous fight, when scientists, athletes, and journalists spent thirty years trying to end an earlier version of sex testing.To listen to this series sponsor-free and support NPR, sign up for Embedded+ in Apple Podcasts or at plus.npr.org.", + "image": "https://media.npr.org/assets/img/2024/07/17/tested_ep3_final_custom-a4b858694623cc10624afe58782233ef834e6e45.jpg", + "date_published": "2024-07-22T03:00:00-04:00", + "date_modified": "2024-07-22T06:17:00-04:00", + "author": { + "name": "Rose Eveleth", + "url": "https://www.npr.org/people/1255807297/rose-eveleth?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2024/07/17/rose-eveleth_vert-56847eb19405581fa8b8b324aa4a80fb0d824bd8.jpg" + }, + "tags": [ + "women's sports", + "World Athletics", + "Paris Olympics 2024", + "The Olympic Games", + "dsd athletes", + "track and field", + "testosterone", + "gender" + ], + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR1059446174.mp3?orgId=1&p=&e=1216507659&size=37954125&d=2372&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 2372 + } + ] + }, + { + "id": "1196982610", + "url": "https://www.npr.org/2024/07/22/1196982610/a-story-about-witches-in-ukraine?utm_medium=JSONFeed&utm_campaign=world", + "title": "A Story About Witches in Ukraine", + "content_html": "

A play about witches is selling out in Ukraine's capital Kyiv. And even though the plot takes place centuries ago, the play's takeaways and parallels to today resonate with Ukrainians. We hear from actors and audience members.

", + "summary": "A play about witches is selling out in Ukraine's capital Kyiv. And even though the plot takes place centuries ago, the play's takeaways and parallels to today resonate with Ukrainians. We hear from actors and audience members.", + "image": "https://media.npr.org/assets/img/2024/07/19/img_1272_pod-d36ce7d149eb1f2fcd4051bfeebf19b1ee39100e.jpg", + "date_published": "2024-07-22T03:00:00-04:00", + "author": { + "name": "Ashley Westerman ", + "url": "https://www.npr.org/people/161527830/ashley-westerman?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2019/10/02/wany5982_vert-704aa20e6bbf7185255b78bd5759969d27027765.jpg" + }, + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR6066114407.mp3?orgId=1&p=&e=1196982610&size=7016743&d=438&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 438 + } + ] + }, + { + "id": "1196982599", + "url": "https://www.npr.org/2024/07/19/1196982599/a-u-s-journalist-is-sentenced-in-russia?utm_medium=JSONFeed&utm_campaign=world", + "title": "A U.S. Journalist is Sentenced in Russia", + "content_html": "

Wall Street Journal correspondent Evan Gershkovich was sentenced to 16 years in a Russian prison on charges of espionage. His employer and the U.S. government have said he is innocent and the trial is a sham. We hear the latest from Moscow and reaction from the U.S.

", + "summary": "Wall Street Journal correspondent Evan Gershkovich was sentenced to 16 years in a Russian prison on charges of espionage. His employer and the U.S. government have said he is innocent and the trial is a sham. We hear the latest from Moscow and reaction from the U.S.", + "image": "https://media.npr.org/assets/img/2024/07/19/gettyimages-2162015977_pod-cada2d082e575dcfb152f8a0941223e7af530e7f.jpg", + "date_published": "2024-07-19T17:00:09-04:00", + "author": { + "name": "Charles Maynes", + "url": "https://www.npr.org/people/825430211/charles-maynes?utm_medium=JSONFeed&utm_campaign=world" + }, + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR8013831996.mp3?orgId=1&p=&e=1196982599&size=7162193&d=447&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 447 + } + ] + }, + { + "id": "1198910415", + "url": "https://www.npr.org/2024/07/19/1198910415/india-river-linking-plan-flood-drought-weather?utm_medium=JSONFeed&utm_campaign=world", + "title": "India's plan to reroute rivers could have unintended consequences on rainfall", + "content_html": "

More than a hundred years ago, a British engineer proposed linking two rivers in India to better irrigate the area and cheaply move goods. The link never happened, but the idea survived. Today, due to extreme flooding in some parts of the country mirrored by debilitating drought in others, India's National Water Development Agency plans to dig thirty links between rivers across the country. It's the largest project of its kind and will take decades to complete. But scientists are worried what moving that much water could do to the land, the people — and even the weather. Host Emily Kwong talks to journalist Sushmita Pathak about her recent story on the project.

Read Sushmita's full story here.

Interested in more science stories like this? Email us at shortwave@npr.org.

", + "summary": "More than a hundred years ago, a British engineer proposed linking two rivers in India to better irrigate the area and cheaply move goods. The link never happened, but the idea survived. Today, due to extreme flooding in some parts of the country mirrored by debilitating drought in others, India's National Water Development Agency plans to dig thirty links between rivers across the country. It's the largest project of its kind and will take decades to complete. But scientists are worried what moving that much water could do to the land, the people — and even the weather. Host Emily Kwong talks to journalist Sushmita Pathak about her recent story on the project. Read Sushmita's full story here.Interested in more science stories like this? Email us at shortwave@npr.org.", + "image": "https://media.npr.org/assets/img/2024/07/18/7.19.24-ep-b9a17baa0d2c62c0d156e4d0c12056f274b7a7a1.jpg", + "date_published": "2024-07-19T03:00:59-04:00", + "date_modified": "2024-07-19T03:01:00-04:00", + "author": { + "name": "Emily Kwong", + "url": "https://www.npr.org/people/767284140/emily-kwong?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2022/10/04/emilykwong_vert-118dc22cc13bf6ccb1ec2f0b54b3051b54c61a9e.jpg" + }, + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR2341365847.mp3?orgId=1&p=&e=1198910415&size=13203793&d=825&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 825 + } + ] + }, + { + "id": "1198909021", + "url": "https://www.npr.org/2024/07/18/1198909021/the-creeping-coup?utm_medium=JSONFeed&utm_campaign=world", + "title": "The Creeping Coup", + "content_html": "

Sudan has been at the center of a deadly and brutal war for over a year. It's the site of the world's largest hunger crisis, and the world's largest displacement crisis.

On the surface, it's a story about two warring generals vying for power – the latest in a long cycle of power struggles that have plagued Sudan for decades. But it's also a story about the U.S. war on terror, Russia's war in Ukraine, and China's global rise.

Today on the show, we turn back the clock more than a century to untangle the complex web that put Sudan on the path to war.

", + "summary": "Sudan has been at the center of a deadly and brutal war for over a year. It's the site of the world's largest hunger crisis, and the world's largest displacement crisis.On the surface, it's a story about two warring generals vying for power – the latest in a long cycle of power struggles that have plagued Sudan for decades. But it's also a story about the U.S. war on terror, Russia's war in Ukraine, and China's global rise.Today on the show, we turn back the clock more than a century to untangle the complex web that put Sudan on the path to war.", + "image": "https://media.npr.org/assets/img/2024/07/17/sudan-military-tank1-2a48163a26f4b1ecc1912cd8be055430640a22d7.jpg", + "date_published": "2024-07-18T03:00:59-04:00", + "date_modified": "2024-07-18T03:01:00-04:00", + "author": { + "name": "Rund Abdelfatah", + "url": "https://www.npr.org/people/705948878/rund-abdelfatah?utm_medium=JSONFeed&utm_campaign=world", + "avatar": "https://media.npr.org/assets/img/2022/05/13/rund-abdelfatah_vert-d22e8facceb8970ebbe1e844f23b55bb90b24637.jpg" + }, + "attachments": [ + { + "url": "https://play.podtrac.com//traffic.megaphone.fm/NPR5239300107.mp3?orgId=1&p=&e=1198909021&size=48416479&d=3025&t=podcast&ft=rss", + "mime_type": "audio/mp3", + "title": "Listen to the Story", + "duration_in_seconds": 3025 + } + ] + } + ] +}