diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-11-13 19:56:19 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-11-13 19:56:19 +0000 |
| commit | 7374edc0773988ac3de4d0480a1005d5d1c8cd6d (patch) | |
| tree | 9080e5bc1fcc9d2a443c2cdb77c035df13065ec3 /app/src/main | |
| parent | 17643797517693664e6d4f5e5911b9855d6c484b (diff) | |
"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/main')
4 files changed, 96 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() + } +} |
