From 5b911d1e3b80418aaa6f73c2462a3e7028038ee4 Mon Sep 17 00:00:00 2001
From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com>
Date: Mon, 26 Oct 2026 17:30:43 +0300
Subject: [PATCH] fix: prefer first-post F95 covers and banners over metadata
thumbnails
---
backend/app/f95.py | 17 +++++++++++++----
backend/tests/test_f95.py | 34 ++++++++++++++++++++++++++++++++--
docs/F95.md | 6 ++++--
3 files changed, 49 insertions(+), 8 deletions(-)
diff --git a/backend/app/f95.py b/backend/app/f95.py
index e19e3c5..50178f0 100644
--- a/backend/app/f95.py
+++ b/backend/app/f95.py
@@ -4,7 +4,7 @@ import urllib.error
import urllib.request
from html import unescape
from html.parser import HTMLParser
-from urllib.parse import urljoin, urlparse
+from urllib.parse import unquote, urljoin, urlparse
THREAD_RE = re.compile(r"^/threads/[^/]+\.\d+/?|^/threads/\d+/?")
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"
@@ -132,6 +132,7 @@ class _Parser(HTMLParser):
self.og_title = ""
self.og_image = None
self.post_image = None
+ self.cover_image = None
self.tags: list[str] = []
self.post: list[str] = []
self.template = ""
@@ -164,10 +165,18 @@ class _Parser(HTMLParser):
self._depth += 1
if self._skip or {"bbCodeSpoiler", "messageHide"} & set(cls):
self._skip += 1
- elif tag == "img" and "bbImage" in cls and not self._skip and not self.post_image:
+ elif tag == "img" and "bbImage" in cls and not self._skip:
for key in ("data-src", "src"):
if candidate := image_url(a.get(key) or ""):
- self.post_image = candidate
+ if not self.post_image:
+ self.post_image = candidate
+ # Prefer a cover/banner explicitly identified in the opening post.
+ # Metadata thumbnails and earlier inline images may be unrelated.
+ name = " ".join((a.get("alt") or "", a.get("title") or "",
+ unquote(urlparse(candidate).path.rsplit("/", 1)[-1])))
+ name = re.sub(r"([a-z])([A-Z])", r"\1_\2", name)
+ if not self.cover_image and re.search(r"(?:^|[^a-z])(?:f95[ _-]*)?(?:cover|banner)(?:[^a-z]|$)", name, re.I):
+ self.cover_image = candidate
break
elif tag == "br" and not self._skip:
self.post.append("\n")
@@ -207,7 +216,7 @@ def parse(html: str) -> dict:
title = re.sub(r"\s*\[[^\]]*\]", "", title)
title = re.sub(r"^(?:[A-Za-z0-9'’ ]{1,20} - )+", "", title).strip()
return {"title": title, "description": _description("".join(p.post)),
- "tags": sorted(set(p.tags)), "image_url": p.og_image or p.post_image,
+ "tags": sorted(set(p.tags)), "image_url": p.cover_image or p.post_image or p.og_image,
"logged_in": p.logged_in}
diff --git a/backend/tests/test_f95.py b/backend/tests/test_f95.py
index 635cf3e..dc9754e 100644
--- a/backend/tests/test_f95.py
+++ b/backend/tests/test_f95.py
@@ -29,11 +29,41 @@ def page(meta="", post="", reply=""):
def test_og_preview_and_existing_metadata():
- result = f95.parse(page(f'',
- '
'))
+ result = f95.parse(page(f''))
assert result == {"title": "Example", "description": "A story", "tags": [], "image_url": IMAGE, "logged_in": None}
+def test_mist_banner_beats_metadata_thumbnail_and_earlier_inline_image():
+ banner = ("https://attachments.f95zone.to/2022/03/1736427_F95Banner_Sideversion.png"
+ "?h=test-signature&e=1793113200&u=42")
+ escaped = banner.replace("&", "&")
+ html = page('',
+ '
'
+ f'',
+ '
')
+ assert f95.parse(html)["image_url"] == banner
+
+
+@pytest.mark.parametrize("attributes", [
+ 'alt="F95 Cover.png"', 'title="Game Cover"',
+ 'data-src="/attachments/123_F95Banner_Sideversion.png"',
+])
+def test_cover_identified_by_alt_title_or_filename(attributes):
+ html = page(post='
'
+ f'
')
+ expected = "/attachments/123_F95Banner_Sideversion.png" if "data-src" in attributes else "/chosen.jpg"
+ assert f95.parse(html)["image_url"] == "https://f95zone.to" + expected
+
+
+def test_first_post_image_preferred_over_og_thumbnail_without_cover_marker():
+ html = page('',
+ '


')
+ assert f95.parse(html)["image_url"] == "https://f95zone.to/first.jpg"
+
+
def test_first_post_lazy_image_fallback_ignores_logo_spoilers_and_replies():
html = page('',
'