diff options
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/data/FeedParser.kt')
| -rw-r--r-- | app/src/main/java/org/terst/feedreader/data/FeedParser.kt | 207 |
1 files changed, 207 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/FeedParser.kt b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt new file mode 100644 index 0000000..cd337d6 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt @@ -0,0 +1,207 @@ +package org.terst.feedreader.data + +import android.util.Xml +import org.xmlpull.v1.XmlPullParser +import java.io.InputStream +import java.time.OffsetDateTime +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.temporal.ChronoField +import java.util.Locale + +data class ParsedItem( + val guid: String?, + val title: String, + val link: String, + val author: String?, + val publishedAt: Long?, + val contentHtml: String? +) + +data class ParsedFeed( + val title: String, + val siteUrl: String?, + val items: List<ParsedItem> +) + +/** + * Hand-rolled RSS 2.0 + Atom parser -- deliberately not a third-party library + * (rome, etc.), per the "simple design" scope: only the fields this app + * actually uses (title/link/guid/author/date/content), nothing else. + */ +object FeedParser { + + fun parse(input: InputStream): ParsedFeed { + val parser = Xml.newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + parser.setInput(input, null) + + // Peek the root element to decide which dialect we're in. + var eventType = parser.eventType + while (eventType != XmlPullParser.START_TAG) { + eventType = parser.next() + } + return when (parser.name.lowercase()) { + "rss" -> parseRss(parser) + "feed" -> parseAtom(parser) + else -> throw IllegalArgumentException("Unrecognized feed root element: ${parser.name}") + } + } + + private fun parseRss(parser: XmlPullParser): ParsedFeed { + var feedTitle = "" + var siteUrl: String? = null + val items = mutableListOf<ParsedItem>() + + var eventType = parser.next() + var depth = 1 + // Track whether we're directly inside <channel> (title/link there) vs inside an <item>. + var inItem = false + var itemGuid: String? = null + var itemTitle = "" + var itemLink = "" + var itemAuthor: String? = null + var itemDate: Long? = null + var itemContent: String? = null + var itemDescription: String? = null + + while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) { + when (eventType) { + XmlPullParser.START_TAG -> { + depth++ + when (parser.name.lowercase()) { + "item" -> { + inItem = true + itemGuid = null; itemTitle = ""; itemLink = ""; itemAuthor = null + itemDate = null; itemContent = null; itemDescription = null + } + "title" -> { + val text = parser.nextText() + if (inItem) itemTitle = text else feedTitle = text + } + "link" -> { + val text = parser.nextText() + if (inItem) itemLink = text else if (siteUrl == null) siteUrl = text + } + "guid" -> if (inItem) itemGuid = parser.nextText() + "author" -> if (inItem) itemAuthor = parser.nextText() + "dc:creator" -> if (inItem) itemAuthor = parser.nextText() + "pubdate" -> if (inItem) itemDate = parseRfc822Date(parser.nextText()) + "content:encoded" -> if (inItem) itemContent = parser.nextText() + "description" -> if (inItem) itemDescription = parser.nextText() + } + } + XmlPullParser.END_TAG -> { + depth-- + if (parser.name.lowercase() == "item") { + inItem = false + items += ParsedItem( + guid = itemGuid ?: itemLink.ifBlank { null }, + title = itemTitle, + link = itemLink, + author = itemAuthor, + publishedAt = itemDate, + contentHtml = itemContent ?: itemDescription + ) + } + } + } + if (depth > 0) eventType = parser.next() + } + return ParsedFeed(feedTitle, siteUrl, items) + } + + private fun parseAtom(parser: XmlPullParser): ParsedFeed { + var feedTitle = "" + var siteUrl: String? = null + val items = mutableListOf<ParsedItem>() + + var eventType = parser.next() + var depth = 1 + var inEntry = false + var entryId: String? = null + var entryTitle = "" + var entryLink = "" + var entryAuthor: String? = null + var entryDate: Long? = null + var entryContent: String? = null + var entrySummary: String? = null + + while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) { + when (eventType) { + XmlPullParser.START_TAG -> { + depth++ + when (parser.name.lowercase()) { + "entry" -> { + inEntry = true + entryId = null; entryTitle = ""; entryLink = ""; entryAuthor = null + entryDate = null; entryContent = null; entrySummary = null + } + "title" -> { + val text = parser.nextText() + if (inEntry) entryTitle = text else feedTitle = text + } + "link" -> { + val rel = parser.getAttributeValue(null, "rel") + val href = parser.getAttributeValue(null, "href") + if (href != null && (rel == null || rel == "alternate")) { + if (inEntry) entryLink = href else if (siteUrl == null) siteUrl = href + } + } + "id" -> if (inEntry) entryId = parser.nextText() + "name" -> if (inEntry && entryAuthor == null) entryAuthor = parser.nextText() + "updated" -> if (inEntry) entryDate = parseIso8601Date(parser.nextText()) + "published" -> if (inEntry) entryDate = parseIso8601Date(parser.nextText()) ?: entryDate + "content" -> if (inEntry) entryContent = parser.nextText() + "summary" -> if (inEntry) entrySummary = parser.nextText() + } + } + XmlPullParser.END_TAG -> { + depth-- + if (parser.name.lowercase() == "entry") { + inEntry = false + items += ParsedItem( + guid = entryId ?: entryLink.ifBlank { null }, + title = entryTitle, + link = entryLink, + author = entryAuthor, + publishedAt = entryDate, + contentHtml = entryContent ?: entrySummary + ) + } + } + } + if (depth > 0) eventType = parser.next() + } + return ParsedFeed(feedTitle, siteUrl, items) + } + + private val rfc822Formatters = listOf( + DateTimeFormatter.RFC_1123_DATE_TIME, + DateTimeFormatterBuilder() + .appendPattern("EEE, d MMM yyyy HH:mm:ss ") + .optionalStart().appendPattern("zzz").optionalEnd() + .optionalStart().appendOffset("+HHMM", "+0000").optionalEnd() + .parseDefaulting(ChronoField.OFFSET_SECONDS, 0) + .toFormatter(Locale.US) + ) + + private fun parseRfc822Date(raw: String): Long? { + val trimmed = raw.trim() + for (formatter in rfc822Formatters) { + try { + return ZonedDateTime.parse(trimmed, formatter).toInstant().toEpochMilli() + } catch (_: Exception) { + // try the next formatter + } + } + return null + } + + private fun parseIso8601Date(raw: String): Long? = try { + OffsetDateTime.parse(raw.trim()).toInstant().toEpochMilli() + } catch (_: Exception) { + null + } +} |
