fix: prefer first-post F95 covers and banners over metadata thumbnails
CI / frontend (push) Failing after 20s
CI / backend (push) Failing after 37s

This commit is contained in:
mrleo1nid committed 2026-10-26 17:30:43 +03:00
1 parent e48521bdfb
commit 5b911d1e3b
3 files changed
+48 -7

No files matched your search

+12 -3
View File
@@ -4,7 +4,7 @@ import urllib.error
import urllib.request import urllib.request
from html import unescape from html import unescape
from html.parser import HTMLParser 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+/?") 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" 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_title = ""
self.og_image = None self.og_image = None
self.post_image = None self.post_image = None
self.cover_image = None
self.tags: list[str] = [] self.tags: list[str] = []
self.post: list[str] = [] self.post: list[str] = []
self.template = "" self.template = ""
@@ -164,10 +165,18 @@ class _Parser(HTMLParser):
self._depth += 1 self._depth += 1
if self._skip or {"bbCodeSpoiler", "messageHide"} & set(cls): if self._skip or {"bbCodeSpoiler", "messageHide"} & set(cls):
self._skip += 1 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"): for key in ("data-src", "src"):
if candidate := image_url(a.get(key) or ""): if candidate := image_url(a.get(key) or ""):
if not self.post_image:
self.post_image = candidate 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 break
elif tag == "br" and not self._skip: elif tag == "br" and not self._skip:
self.post.append("\n") self.post.append("\n")
@@ -207,7 +216,7 @@ def parse(html: str) -> dict:
title = re.sub(r"\s*\[[^\]]*\]", "", title) title = re.sub(r"\s*\[[^\]]*\]", "", title)
title = re.sub(r"^(?:[A-Za-z0-9'’ ]{1,20} - )+", "", title).strip() title = re.sub(r"^(?:[A-Za-z0-9'’ ]{1,20} - )+", "", title).strip()
return {"title": title, "description": _description("".join(p.post)), 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} "logged_in": p.logged_in}
+32 -2
View File
@@ -29,11 +29,41 @@ def page(meta="", post="", reply=""):
def test_og_preview_and_existing_metadata(): def test_og_preview_and_existing_metadata():
result = f95.parse(page(f'<meta property="og:image" content="{IMAGE}">', result = f95.parse(page(f'<meta property="og:image" content="{IMAGE}">'))
'<img class="bbImage" src="/attachments/other.jpg">'))
assert result == {"title": "Example", "description": "A story", "tags": [], "image_url": IMAGE, "logged_in": None} 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("&", "&amp;")
html = page('<meta property="og:image" content="/attachments/thumbnail.jpg">',
'<img class="bbImage" src="/attachments/screenshot.jpg">'
f'<img src="{escaped}" data-src="{escaped}" data-url="" '
'class="bbImage lazyloaded" data-zoom-target="1" alt="F95 Cover.png" '
'title="F95 Cover.png" width="1200" height="500" loading="lazy">',
'<img class="bbImage" src="/attachments/reply-cover.png">')
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='<img class="bbImage" src="/first.jpg">'
f'<img class="bbImage" src="/chosen.jpg" {attributes}>')
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('<meta property="og:image" content="/thumbnail.jpg">',
'<div class="bbCodeSpoiler"><img class="bbImage" src="/hidden-cover.png"></div>'
'<img class="bbImage" src="/first.jpg"><img class="bbImage" src="/second.jpg">')
assert f95.parse(html)["image_url"] == "https://f95zone.to/first.jpg"
def test_first_post_lazy_image_fallback_ignores_logo_spoilers_and_replies(): def test_first_post_lazy_image_fallback_ignores_logo_spoilers_and_replies():
html = page('<meta property="og:image" content="/styles/logo.png">', html = page('<meta property="og:image" content="/styles/logo.png">',
'<img class="avatar" src="/avatar.jpg">' '<img class="avatar" src="/avatar.jpg">'
+4 -2
View File
@@ -46,8 +46,10 @@ backend. Шифрование использует `SECRET_KEY`; при смен
потребуется заново добавить cookie. Общие `F95_COOKIE` и `F95_USER_AGENT` из `.env` потребуется заново добавить cookie. Общие `F95_COOKIE` и `F95_USER_AGENT` из `.env`
больше не используются; перезапуск сервера для изменения личной сессии не требуется. больше не используются; перезапуск сервера для изменения личной сессии не требуется.
Импорт выбирает `og:image`, а при его отсутствии — первую доступную `bbImage` Импорт сначала ищет обложку или баннер среди `bbImage` первого сообщения вне скрытых
из первого сообщения вне скрытых блоков. Поддерживаются изображения на блоков: по `cover`/`banner` в `alt`, `title` или имени файла (например, F95 Cover.png
или F95Banner_Sideversion.png). Затем используется первая доступная `bbImage`
первого сообщения, и только при её отсутствии — `og:image`. Поддерживаются изображения на
`f95zone.to` и его поддоменах; внешние хостинги пропускаются. Скачивание ограничено `f95zone.to` и его поддоменах; внешние хостинги пропускаются. Скачивание ограничено
20 МиБ, переходы за пределы F95zone блокируются до отправки cookie. 20 МиБ, переходы за пределы F95zone блокируются до отправки cookie.
Если изображение скачать не удалось, текстовые данные импортируются с сообщением Если изображение скачать не удалось, текстовые данные импортируются с сообщением