fix: prefer first-post F95 covers and banners over metadata thumbnails
This commit is contained in:
1 parent
e48521bdfb
commit
5b911d1e3b
3 files changed
+49
-8
No files matched your search
+13
-4
@@ -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 ""):
|
||||||
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
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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("&", "&")
|
||||||
|
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
@@ -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.
|
||||||
Если изображение скачать не удалось, текстовые данные импортируются с сообщением
|
Если изображение скачать не удалось, текстовые данные импортируются с сообщением
|
||||||
|
|||||||
Reference in new issue
Block a user