diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 08:08:58 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 08:08:58 +0000 |
| commit | d8ce4b3c0f383e844b125d2d3abd618715bb3c89 (patch) | |
| tree | b586ff9b42392f2c44645773c2a2f2aadb9db406 /app/src/main/java/org/terst/feedreader/readability | |
Initial commit: Feeds, an Android RSS reader
Feed subscriptions (RSS 2.0 + Atom, hand-rolled parser), OPML
import/export, WorkManager periodic fetch with conditional GET,
Room-backed local storage (feeds/articles/filter rules), keyword
mute/highlight filtering.
Reader mode: on-device extraction via Mozilla's Readability.js
(vendored from github.com/mozilla/readability, pinned to release
0.6.0) run in a hidden WebView, for feed articles whose provided
content is too thin to be the real article body.
Save: explicit action (from a feed article, or the Android share-sheet
target for any URL) archives to Stash via POST /artifacts?sync=true,
while a local on-device copy (Readability-extracted) is always kept so
saved articles read offline regardless of Stash's reachability.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/readability')
| -rw-r--r-- | app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt | 95 |
1 files changed, 95 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt new file mode 100644 index 0000000..9be08b0 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt @@ -0,0 +1,95 @@ +package org.terst.feedreader.readability + +import android.annotation.SuppressLint +import android.content.Context +import android.webkit.WebView +import android.webkit.WebViewClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.coroutines.resume + +@Serializable +data class ExtractedArticle( + val title: String? = null, + val content: String? = null, // HTML + val textContent: String? = null, + val excerpt: String? = null, + val error: String? = null +) + +/** + * Runs Mozilla's Readability.js (vendored in assets/Readability.js, pinned + * to release 0.6.0) against a page loaded in a hidden WebView, for feed + * articles whose feedContentHtml is missing or too short to be the real + * article body. WebView APIs are main-thread-only, so this hops to + * Dispatchers.Main for the load+eval and suspends the calling coroutine + * until the JS callback fires (or the timeout elapses). + */ +object ReaderExtractor { + private const val TIMEOUT_MS = 15_000L + private val json = Json { ignoreUnknownKeys = true } + + suspend fun extract(context: Context, url: String): ExtractedArticle? = + withTimeoutOrNull(TIMEOUT_MS) { + withContext(Dispatchers.Main) { + extractOnMainThread(context, url) + } + } + + @SuppressLint("SetJavaScriptEnabled") + private suspend fun extractOnMainThread(context: Context, url: String): ExtractedArticle? = + suspendCancellableCoroutine { continuation -> + val webView = WebView(context) + webView.settings.javaScriptEnabled = true + + fun finish(result: ExtractedArticle?) { + if (continuation.isActive) continuation.resume(result) + webView.stopLoading() + webView.destroy() + } + + webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, finishedUrl: String) { + val readabilityJs = context.assets.open("Readability.js").bufferedReader().use { it.readText() } + val script = """ + (function() { + try { + $readabilityJs + var article = new Readability(document.cloneNode(true)).parse(); + return JSON.stringify(article); + } catch (e) { + return JSON.stringify({error: e.toString()}); + } + })(); + """.trimIndent() + + view.evaluateJavascript(script) { rawResult -> + // evaluateJavascript's callback value is itself JSON-encoded (our + // JSON.stringify(...) string comes back wrapped in an extra layer + // of JSON string-quoting), so this needs decoding twice. + val unwrapped = runCatching { json.decodeFromString<String>(rawResult) }.getOrNull() + val article = unwrapped?.let { + runCatching { json.decodeFromString<ExtractedArticle>(it) }.getOrNull() + } + finish(article) + } + } + + override fun onReceivedError( + view: WebView, + errorCode: Int, + description: String?, + failingUrl: String? + ) { + finish(ExtractedArticle(error = description ?: "Failed to load $failingUrl")) + } + } + + continuation.invokeOnCancellation { webView.destroy() } + webView.loadUrl(url) + } +} |
