From 7e1dfd5b3ccfad7e1e524c534a12c6bd4dcf786a Mon Sep 17 00:00:00 2001 From: jay Date: Sun, 31 May 2026 13:03:20 +0000 Subject: [PATCH] Reject branded/generic share images; hero prefers a clean illustrated story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - og:image enrichment now skips branded/generic share images (BBC 'branded_news' with its burned-in logo, NPR 'facebook-default', etc.) and keeps the first real article image — so no competitor logo lands on our hero. Cleared the few already-stored branded URLs so they re-enrich. - Hero selection now prefers a gentle + readable story that also HAS a (clean) image, falling back to gentle-readable, then gentle. The lead is visual when possible, typographic otherwise — never branded. (The '7 cards' report was a stale browser cache: the brief stores 7 and the built JS requests 7; a hard refresh shows all seven.) Tests: branded/generic image rejection (87 total). Co-Authored-By: Claude Opus 4.8 (1M context) --- goodnews/api.py | 13 +++++++++---- goodnews/enrich.py | 30 ++++++++++++++++++++++++++++-- tests/test_enrich.py | 11 +++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/goodnews/api.py b/goodnews/api.py index ca0cf67..4b55324 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -58,13 +58,18 @@ def get_conn(): def _pick_lead(items: list[dict]) -> list[dict]: - """Lead with a gentle, readable story when possible. + """Lead with a gentle, readable, ideally illustrated story. - Tries gentle-and-readable first, then gentle, then leaves the order alone. - Charged or paywalled stories still appear in the set — they just don't lead. + Preference order: gentle + readable + has an image, then gentle + readable, + then gentle, then leave the order alone. Charged/paywalled/imageless stories + still appear in the set — they just don't lead. """ + def gentle(a: dict) -> bool: + return safe_to_lead(a) and not is_paywalled(a.get("canonical_url")) + for ok in ( - lambda a: safe_to_lead(a) and not is_paywalled(a.get("canonical_url")), + lambda a: gentle(a) and bool(a.get("image_url")), + gentle, safe_to_lead, ): for i, a in enumerate(items): diff --git a/goodnews/enrich.py b/goodnews/enrich.py index 37e01b4..f36a707 100644 --- a/goodnews/enrich.py +++ b/goodnews/enrich.py @@ -34,6 +34,28 @@ MAX_REDIRECTS = 3 _META_RE = re.compile(rb"]*>", re.IGNORECASE) _HEAD_END_RE = re.compile(rb"", re.IGNORECASE) +# Substrings that mark a branded/generic share image rather than the article's +# own picture (e.g. BBC burns "BBC NEWS" into branded_news; NPR uses a default). +# We'd rather show no image (typographic hero) than a competitor logo. +_GENERIC_IMAGE_MARKERS = ( + "branded_news", + "facebook-default", + "default-wide", + "default-fb", + "og-default", + "default-og", + "twitter-default", + "default-image", + "/placeholder", + "share-default", + "social-default", +) + + +def _is_generic_image(url: str) -> bool: + lowered = url.lower() + return any(marker in lowered for marker in _GENERIC_IMAGE_MARKERS) + def _attr(tag: bytes, name: bytes) -> bytes | None: m = re.search(name + rb"""\s*=\s*["']([^"']*)["']""", tag, re.IGNORECASE) @@ -47,8 +69,12 @@ def og_image_from_html(html: bytes) -> str | None: key = _attr(tag, b"property") or _attr(tag, b"name") if key and key.lower() in (b"og:image", b"og:image:url", b"twitter:image"): content = _attr(tag, b"content") - if content: - return canonicalize_url(content.decode("utf-8", "replace")) + if not content: + continue + image = canonicalize_url(content.decode("utf-8", "replace")) + # Skip branded/generic share images; keep scanning for a real one. + if image and not _is_generic_image(image): + return image return None diff --git a/tests/test_enrich.py b/tests/test_enrich.py index 5d27f5d..8510caf 100644 --- a/tests/test_enrich.py +++ b/tests/test_enrich.py @@ -69,3 +69,14 @@ def test_enrich_upgrades_existing_feed_image(tmp_path): assert found == 1 assert c.execute("SELECT image_url FROM articles WHERE id=1").fetchone()["image_url"] == "https://bbc.com/big-og.jpg" c.close() + + +def test_rejects_branded_and_generic_share_images(): + assert og_image_from_html(b'') is None + assert og_image_from_html(b'') is None + # a real article image still comes through + assert og_image_from_html(b'') == "https://x.com/real-photo.jpg" + # if the first og is branded but a later one is real, take the real one + html = (b'' + b'') + assert og_image_from_html(html) == "https://x.com/article.jpg"