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'F95 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='' + 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('', '' diff --git a/docs/F95.md b/docs/F95.md index 2a7010e..8888bc3 100644 --- a/docs/F95.md +++ b/docs/F95.md @@ -46,8 +46,10 @@ backend. Шифрование использует `SECRET_KEY`; при смен потребуется заново добавить 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` и его поддоменах; внешние хостинги пропускаются. Скачивание ограничено 20 МиБ, переходы за пределы F95zone блокируются до отправки cookie. Если изображение скачать не удалось, текстовые данные импортируются с сообщением