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"