summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-11-13 19:56:19 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-11-13 19:56:19 +0000
commit7374edc0773988ac3de4d0480a1005d5d1c8cd6d (patch)
tree9080e5bc1fcc9d2a443c2cdb77c035df13065ec3 /app/src
parent17643797517693664e6d4f5e5911b9855d6c484b (diff)
Take the giant Google badge out of the readerHEADmaster
"Remove giant Google icon", reported on a West Hawaii Today article. It is the "Add as a preferred source on Google" badge that sits beside the byline. Giant because the page caps it at 120px with its own stylesheet and the reader drops the page's CSS, so a small badge on the site becomes a full-width image above the first paragraph. Matched on the link's address rather than the class, and that is forced rather than preferred: running the vendored Readability.js over the real page shows the wrapper's preferred_source_badge class does not survive extraction at all, so a class rule would match nothing. The anchor does survive, and its address is Google's own and identical for every publisher in the programme -- where the badge's image filename sits under this newspaper's theme directory and is not. So the address is both the only signal left and the one that generalises. A link with words in it is never touched. An article may discuss the programme and link to it in a sentence, and that sentence is the author's. Both cleaners now run from one entry point. There are two places a body is cleaned -- a fresh extraction on the way out, a cached one on the way back in -- and a cached article is cleaned in place rather than discarded, so a cleaner wired into only the fresh path would leave every already-read article carrying litter nothing would ever remove. Peter's report says the article was cached, which is exactly that case. Pinned against the real thing: the reported article, put through the real extractor, loses the badge and keeps its text and byline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/CachedExtraction.kt4
-rw-r--r--app/src/main/java/org/terst/feedreader/readability/ArticleLitter.kt24
-rw-r--r--app/src/main/java/org/terst/feedreader/readability/EvalDecision.kt2
-rw-r--r--app/src/main/java/org/terst/feedreader/readability/PreferredSourceBadge.kt69
-rw-r--r--app/src/test/java/org/terst/feedreader/readability/ArticleLitterTest.kt47
-rw-r--r--app/src/test/java/org/terst/feedreader/readability/PreferredSourceBadgeTest.kt105
-rw-r--r--app/src/test/resources/west-hawaii-today-usps-extracted.html18
7 files changed, 266 insertions, 3 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/CachedExtraction.kt b/app/src/main/java/org/terst/feedreader/data/CachedExtraction.kt
index 63614d1..39f39a5 100644
--- a/app/src/main/java/org/terst/feedreader/data/CachedExtraction.kt
+++ b/app/src/main/java/org/terst/feedreader/data/CachedExtraction.kt
@@ -1,7 +1,7 @@
package org.terst.feedreader.data
import org.terst.feedreader.readability.ConsentNotice
-import org.terst.feedreader.readability.RecirculationWidgets
+import org.terst.feedreader.readability.ArticleLitter
import org.terst.feedreader.readability.UnrenderedTex
/** What a cached `articles.extractedContent` value turned out to be worth. */
@@ -64,7 +64,7 @@ suspend fun loadCachedExtraction(
// article carrying the publisher's promo blocks is a good article with litter in it,
// not a poisoned row: cleaning it in place shows the right thing immediately, where
// discarding it would throw away a usable body and fetch the identical one back.
- content = RecirculationWidgets.strip(cached),
+ content = ArticleLitter.strip(cached),
discardedConsentNotice = false
)
diff --git a/app/src/main/java/org/terst/feedreader/readability/ArticleLitter.kt b/app/src/main/java/org/terst/feedreader/readability/ArticleLitter.kt
new file mode 100644
index 0000000..decddc6
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/readability/ArticleLitter.kt
@@ -0,0 +1,24 @@
+package org.terst.feedreader.readability
+
+/**
+ * Everything that gets cleaned out of an extracted article body, in one call.
+ *
+ * There are two places a body is cleaned -- a fresh extraction on its way out of [EvalDecision],
+ * and a cached one on its way back in through `loadCachedExtraction` -- and they must stay in
+ * step. A cached article is cleaned in place rather than discarded, so a cleaner wired into only
+ * the fresh path would leave every already-read article carrying litter that nothing would ever
+ * remove: the body is good, so it is never re-fetched.
+ *
+ * Hence one entry point rather than a list of calls repeated at both sites. Adding a cleaner here
+ * reaches both; adding one to a call site reaches one, and which one it misses would depend on
+ * which file was open at the time.
+ *
+ * None of these discard an article. They remove things from inside it, which is why they sit
+ * apart from [ConsentNotice] and [UnrenderedTex] -- those two decide a body is not worth keeping.
+ */
+object ArticleLitter {
+
+ /** [html] with the publisher's promo furniture removed. */
+ fun strip(html: String?): String? =
+ PreferredSourceBadge.strip(RecirculationWidgets.strip(html))
+}
diff --git a/app/src/main/java/org/terst/feedreader/readability/EvalDecision.kt b/app/src/main/java/org/terst/feedreader/readability/EvalDecision.kt
index dd77014..d0bd1cd 100644
--- a/app/src/main/java/org/terst/feedreader/readability/EvalDecision.kt
+++ b/app/src/main/java/org/terst/feedreader/readability/EvalDecision.kt
@@ -44,7 +44,7 @@ internal fun decideEvalOutcome(
// never reach `articles.extractedContent` either.
usable -> EvalDecision.Done(
ExtractionResult.Success(
- article!!.copy(content = RecirculationWidgets.strip(article.content))
+ article!!.copy(content = ArticleLitter.strip(article.content))
)
)
diff --git a/app/src/main/java/org/terst/feedreader/readability/PreferredSourceBadge.kt b/app/src/main/java/org/terst/feedreader/readability/PreferredSourceBadge.kt
new file mode 100644
index 0000000..4ea1370
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/readability/PreferredSourceBadge.kt
@@ -0,0 +1,69 @@
+package org.terst.feedreader.readability
+
+/**
+ * Removes the "Add as a preferred source on Google" badge publishers put beside the byline.
+ *
+ * Reported live on a West Hawaii Today article: "remove giant Google icon". Giant because the
+ * page caps it with its own stylesheet -- `.preferred_source_badge { max-width: 120px }` -- and
+ * the reader drops the page's CSS, so what is a small badge on the site becomes a full-width
+ * image in the reader, above the first paragraph.
+ *
+ * The signal is the link's address, not the class or the image filename, and that is forced
+ * rather than chosen. Confirmed by running the vendored Readability.js over the real page: the
+ * wrapper's `preferred_source_badge` class does not survive extraction at all -- the only class
+ * left in the output is Readability's own -- so a class rule would match nothing. What does
+ * survive is the anchor, intact:
+ *
+ * ```
+ * <p><a href="https://www.google.com/preferences/source?q=westhawaiitoday.com" target="_blank"
+ * rel="noopener"> <img src=".../google_preferred_source_badge_light_en.png"
+ * alt="Add as a preferred source on Google"> </a> </p>
+ * ```
+ *
+ * The address is Google's own and identical for every publisher in the programme; the image
+ * filename sits under the site's theme directory and is not. So matching the address is both the
+ * only thing available and the one that generalises past this one newspaper.
+ *
+ * A link with real words in it is never touched, which is the same safety valve [ConsentNotice]
+ * and [RecirculationWidgets] use: an article may legitimately link to that page and say so in
+ * writing, and that sentence is the author's, not litter. Only a link whose entire content is an
+ * image goes.
+ */
+object PreferredSourceBadge {
+
+ /** Google's address for the programme. Not the badge image, which is per-publisher. */
+ private const val BADGE_LINK = "google.com/preferences/source"
+
+ private val ANCHOR = Regex("""<a\b[^>]*>.*?</a\s*>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
+ private val TAG = Regex("<[^>]*>")
+ private val EMPTY_PARAGRAPH = Regex("""<p>\s*</p>""", RegexOption.IGNORE_CASE)
+
+ /** [html] with any preferred-source badge removed. */
+ fun strip(html: String?): String? =
+ if (html.isNullOrBlank() || !html.contains(BADGE_LINK, ignoreCase = true)) {
+ html
+ } else {
+ removeBadges(html)
+ }
+
+ private fun removeBadges(html: String): String {
+ val withoutBadges = ANCHOR.replace(html) { match ->
+ if (isBadge(match.value)) "" else match.value
+ }
+ // The badge is normally the only thing in its paragraph, and a <p> holding nothing but
+ // the whitespace around the image it used to wrap still takes up a line on the screen.
+ return EMPTY_PARAGRAPH.replace(withoutBadges, "")
+ }
+
+ /**
+ * A badge is a link to the programme whose whole content is an image.
+ *
+ * Both halves are required. The address alone would take a sentence linking to the page with
+ * it; "contains an image" alone would take every photo credit on the site.
+ */
+ private fun isBadge(anchor: String): Boolean {
+ val opening = anchor.substringBefore('>')
+ if (!opening.contains(BADGE_LINK, ignoreCase = true)) return false
+ return TAG.replace(anchor, "").isBlank()
+ }
+}
diff --git a/app/src/test/java/org/terst/feedreader/readability/ArticleLitterTest.kt b/app/src/test/java/org/terst/feedreader/readability/ArticleLitterTest.kt
new file mode 100644
index 0000000..121783b
--- /dev/null
+++ b/app/src/test/java/org/terst/feedreader/readability/ArticleLitterTest.kt
@@ -0,0 +1,47 @@
+package org.terst.feedreader.readability
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The single entry point both cleaning sites call.
+ *
+ * The thing worth testing is that it applies *every* cleaner, because the failure it exists to
+ * prevent is a cleaner that only runs on one of the two paths.
+ */
+class ArticleLitterTest {
+
+ private val recirculationWidget =
+ """<div data-mrf-recirculation="IL_x" data-testid="text-html">""" +
+ """<p><h3><span>Trending</span></h3></p></div>"""
+
+ private val preferredSourceBadge =
+ """<p><a href="https://www.google.com/preferences/source?q=westhawaiitoday.com">""" +
+ """<img src="https://example.com/google_preferred_source_badge_light_en.png" """ +
+ """alt="Add as a preferred source on Google"></a></p>"""
+
+ @Test
+ fun `removes both kinds of litter from one body`() {
+ val html = "$preferredSourceBadge<p>Real sentence.</p>$recirculationWidget"
+
+ val cleaned = ArticleLitter.strip(html)!!
+
+ assertFalse("badge survived: $cleaned", cleaned.contains("preferences/source"))
+ assertFalse("widget survived: $cleaned", cleaned.contains("Trending"))
+ assertTrue(cleaned.contains("Real sentence."))
+ }
+
+ @Test
+ fun `leaves a clean article untouched`() {
+ val html = "<p>Nothing to clean.</p>"
+
+ assertEquals(html, ArticleLitter.strip(html))
+ }
+
+ @Test
+ fun `copes with nothing`() {
+ assertEquals(null, ArticleLitter.strip(null))
+ }
+}
diff --git a/app/src/test/java/org/terst/feedreader/readability/PreferredSourceBadgeTest.kt b/app/src/test/java/org/terst/feedreader/readability/PreferredSourceBadgeTest.kt
new file mode 100644
index 0000000..edbb9d0
--- /dev/null
+++ b/app/src/test/java/org/terst/feedreader/readability/PreferredSourceBadgeTest.kt
@@ -0,0 +1,105 @@
+package org.terst.feedreader.readability
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The "Add as a preferred source on Google" badge beside a byline.
+ *
+ * Reported live: "remove giant Google icon" on West Hawaii Today. The markup below is what the
+ * vendored Readability.js actually leaves behind on that article -- the wrapper's class is gone
+ * by then, which is why the link's address is what gets matched.
+ */
+class PreferredSourceBadgeTest {
+
+ private val realBadge =
+ """<p><a href="https://www.google.com/preferences/source?q=westhawaiitoday.com" """ +
+ """target="_blank" rel="noopener"> """ +
+ """<img src="https://www.westhawaiitoday.com/wp-content/themes/2024-cmg/media/img/""" +
+ """google_preferred_source_badge_light_en.png" alt="Add as a preferred source on Google"> </a> </p>"""
+
+ @Test
+ fun `strips the badge that sits beside the byline`() {
+ val html = "<div><p>By A Reporter</p>$realBadge</div><p>The first real sentence.</p>"
+
+ val cleaned = PreferredSourceBadge.strip(html)!!
+
+ assertFalse("the badge is still there: $cleaned", cleaned.contains("preferred_source_badge"))
+ assertFalse(cleaned.contains("google.com/preferences/source"))
+ assertTrue(cleaned.contains("By A Reporter"))
+ assertTrue(cleaned.contains("The first real sentence."))
+ }
+
+ /** It leaves no blank gap where it used to be: the paragraph held nothing else. */
+ @Test
+ fun `takes the emptied paragraph with it`() {
+ val cleaned = PreferredSourceBadge.strip("<p>Real.</p>$realBadge")!!
+
+ assertEquals("<p>Real.</p>", cleaned.trim())
+ }
+
+ /**
+ * The safety valve. An article may discuss the programme and link to it in a sentence, and
+ * that sentence belongs to whoever wrote it.
+ */
+ @Test
+ fun `leaves a link alone when it has words in it`() {
+ val html = """<p>You can <a href="https://www.google.com/preferences/source?q=x">""" +
+ """add us as a preferred source</a> if you like.</p>"""
+
+ assertEquals(html, PreferredSourceBadge.strip(html))
+ }
+
+ @Test
+ fun `leaves an ordinary image link alone`() {
+ val html = """<p><a href="https://example.com/photo"><img src="https://example.com/p.jpg"></a></p>"""
+
+ assertEquals(html, PreferredSourceBadge.strip(html))
+ }
+
+ @Test
+ fun `leaves an article with no badge exactly as it was`() {
+ val html = "<p>Nothing to do here.</p>"
+
+ assertEquals(html, PreferredSourceBadge.strip(html))
+ }
+
+ /**
+ * The whole article, as the app would really hold it.
+ *
+ * The fixture is the reported USPS story put through the vendored Readability.js exactly as
+ * the app does -- the cases above prove the matcher against the badge on its own, this
+ * proves it meets reality in one piece. Same arrangement as the recirculation widgets, on
+ * the same newspaper.
+ */
+ @Test
+ fun `the real extracted article loses the badge and keeps its text`() {
+ val extracted = checkNotNull(
+ javaClass.getResourceAsStream("/west-hawaii-today-usps-extracted.html")
+ ).bufferedReader().use { it.readText() }
+ assertEquals(
+ "fixture should carry the reported bug",
+ 1,
+ Regex("google_preferred_source_badge").findAll(extracted).count(),
+ )
+
+ val cleaned = PreferredSourceBadge.strip(extracted)!!
+
+ assertEquals(0, Regex("google_preferred_source_badge").findAll(cleaned).count())
+ assertFalse(cleaned.contains("preferences/source"))
+ assertTrue(cleaned.contains("The U.S. Postal Service is moving to raise"))
+ assertTrue(cleaned.contains("Cailley LaPara"))
+ assertTrue(
+ "barely any of the article should have gone with the badge",
+ cleaned.length > extracted.length - 400,
+ )
+ }
+
+ @Test
+ fun `copes with nothing`() {
+ assertEquals(null, PreferredSourceBadge.strip(null))
+ assertEquals("", PreferredSourceBadge.strip(""))
+ }
+}
diff --git a/app/src/test/resources/west-hawaii-today-usps-extracted.html b/app/src/test/resources/west-hawaii-today-usps-extracted.html
new file mode 100644
index 0000000..d52ec07
--- /dev/null
+++ b/app/src/test/resources/west-hawaii-today-usps-extracted.html
@@ -0,0 +1,18 @@
+<div id="readability-page-1" class="page"><div id="article_info">
+ <div>
+ <p>Published 12:05 am Friday, November 13, 2026</p>
+ <p>
+ <strong>By Cailley LaPara Bloomberg News</strong>
+ </p>
+ </div>
+ <p><a href="https://www.google.com/preferences/source?q=westhawaiitoday.com" target="_blank" rel="noopener">
+ <img src="https://www.westhawaiitoday.com/wp-content/themes/2024-cmg/media/img/google_preferred_source_badge_light_en.png" alt="Add as a preferred source on Google">
+ </a>
+ </p>
+ </div><div id="article_content"><p>The U.S. Postal Service is moving to raise and make permanent its fuel surcharge, which the agency introduced for the first time earlier this year.</p>
+<p>The fuel surcharge will increase to 11% from 8% starting in January, the USPS said in a statement Thursday. Certain domestic shipping services also will increase by an average of 5.9%. That adjustment is in line with the typical annual rate changes that other large parcel carriers have announced for the year ahead.</p>
+<p>The fuel surcharge hike comes after diesel prices hit record highs due to the war in Iran. The USPS implemented its first-ever fuel related fee in April, soon after the war started and diesel prices jumped. Costs have continued to increase throughout the year.</p>
+<p>Shares of parcel giants United Parcel Service Inc. fell 1% and FedEx Corp. slipped less than 1% on Thursday after the Postal Service announced the planned price hikes. Both companies are contending with similar cost headwinds and are leaning heavily on pricing to boost revenue.</p>
+<p>The announcement comes days after the USPS reported annual financial results which showed that the agency lost $6.5 billion in fiscal year 2026, compared with $9 billion the year prior.</p>
+<p>The USPS faces a dire financial situation due to its massive operational costs and waning mail volume. Postmaster General David Steiner has said the agency could run out of money as soon as February, and has halted discretionary spending and certain retirement contributions to stave off a liquidity crisis. He has also asked Congress for help solving some of the agency’s structural financial issues.</p>
+</div></div> \ No newline at end of file