feat: import F95 covers using personal encrypted sessions
CI / frontend (push) Failing after 16s
CI / backend (push) Failing after 36s

This commit is contained in:
mrleo1nid committed 2026-10-26 15:39:41 +03:00
1 parent 3f51f06fec
commit 219005d16a
16 files changed
+763 -26

No files matched your search

+103 -10
View File
@@ -1,24 +1,100 @@
"""Reads title, description and tags from a public F95zone thread (no login, first post only)."""
"""Reads F95zone metadata with an optional personal session cookie."""
import re
import urllib.error
import urllib.request
from html import unescape
from html.parser import HTMLParser
from urllib.parse import urlparse
from urllib.parse import 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"
MAX_BYTES = 3 * 1024 * 1024
MAX_IMAGE_BYTES = 20 * 1024 * 1024
# first-post fields that are not part of the description itself
STOP_LABELS = ("thread updated", "release date", "developer", "censored", "version", "os",
"language", "store", "genre", "installation", "changelog", "developer notes",
"download", "animations")
def _request(url: str, referer: str | None = None, cookie: str = "", user_agent: str = "") -> urllib.request.Request:
headers = {"User-Agent": user_agent or UA, "Accept-Language": "en-US,en;q=0.8"}
if referer:
headers["Referer"] = referer
request = urllib.request.Request(url, headers=headers)
cookie = cookie.strip()
if "\r" in cookie or "\n" in cookie:
raise F95Error("Некорректные cookie F95zone")
if cookie:
# Redirect handlers explicitly reapply the cookie after validating the next host.
request.add_unredirected_header("Cookie", cookie)
return request
def image_url(value: str) -> str | None:
"""Only download images hosted by F95; never follow arbitrary post links."""
try:
url = urljoin("https://f95zone.to/", value.strip())
p = urlparse(url)
port = p.port
except ValueError:
return None
host = (p.hostname or "").lower()
if (value.strip() and p.scheme in ("http", "https")
and (host == "f95zone.to" or host.endswith(".f95zone.to"))
and not p.username and not p.password and port in (None, 80, 443)
and not p.path.startswith("/styles/") and "logo" not in p.path.lower()):
return p._replace(scheme="https", fragment="").geturl()
return None
class _F95Redirect(urllib.request.HTTPRedirectHandler):
images = False
def redirect_request(self, req, fp, code, msg, headers, newurl):
target = image_url(newurl)
if not target or (not self.images and urlparse(target).hostname not in ("f95zone.to", "www.f95zone.to")):
raise F95Error("Неожиданный редирект F95zone")
redirected = super().redirect_request(req, fp, code, msg, headers, target)
if redirected is not None:
cookie = req.get_header("Cookie", "")
if cookie:
redirected.add_unredirected_header("Cookie", cookie)
return redirected
class _ImageRedirect(_F95Redirect):
images = True
def fetch_image(url: str, thread_url: str, cookie: str = "", user_agent: str = "") -> bytes:
target = image_url(url)
if not target:
raise F95Error("Недопустимая ссылка на превью F95zone")
request = _request(target, thread_url, cookie, user_agent)
try:
with urllib.request.build_opener(_ImageRedirect()).open(request, timeout=15) as response:
if response.headers.get_content_type() == "text/html":
raise F95AuthError("F95zone запрашивает авторизацию для превью")
data = response.read(MAX_IMAGE_BYTES + 1)
if len(data) > MAX_IMAGE_BYTES:
raise F95Error("Превью F95zone слишком большое")
return data
except urllib.error.HTTPError as e:
if e.code in (401, 403):
raise F95AuthError("F95zone не принял cookie или ограничил доступ к превью") from e
raise F95Error("Не удалось скачать превью F95zone") from e
except OSError as e:
raise F95Error("Не удалось скачать превью F95zone") from e
class F95Error(Exception):
pass
class F95AuthError(F95Error):
pass
def validate_url(url: str) -> str:
p = urlparse(url.strip())
if p.scheme not in ("http", "https") or (p.hostname or "").lower() not in ("f95zone.to", "www.f95zone.to"):
@@ -28,10 +104,10 @@ def validate_url(url: str) -> str:
return f"https://f95zone.to{p.path}"
def fetch(url: str) -> str:
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept-Language": "en-US,en;q=0.8"})
def fetch(url: str, cookie: str = "", user_agent: str = "") -> str:
req = _request(url, cookie=cookie, user_agent=user_agent)
try:
with urllib.request.urlopen(req, timeout=15) as r: # noqa: S310 - host is validated
with urllib.request.build_opener(_F95Redirect()).open(req, timeout=15) as r:
final = urlparse(r.geturl())
if (final.hostname or "") not in ("f95zone.to", "www.f95zone.to"):
raise F95Error("Неожиданный редирект")
@@ -39,6 +115,8 @@ def fetch(url: str) -> str:
except F95Error:
raise
except urllib.error.HTTPError as e:
if e.code in (401, 403):
raise F95AuthError("F95zone не принял cookie или ограничил доступ к теме") from e
if e.code >= 500:
raise F95Error(f"F95zone сейчас недоступен (ответил {e.code}), попробуйте позже")
raise F95Error(f"F95zone ответил {e.code} (тема недоступна или сайт блокирует запрос)")
@@ -52,9 +130,12 @@ class _Parser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.og_title = ""
self.og_image = None
self.post_image = None
self.tags: list[str] = []
self.post: list[str] = []
self.template = ""
self.logged_in = None
self._in_tag = False
self._tag_buf = ""
self._depth = 0 # div depth inside the first bbWrapper; 0 = outside
@@ -66,8 +147,11 @@ class _Parser(HTMLParser):
cls = (a.get("class") or "").split()
if tag == "html":
self.template = a.get("data-template") or ""
self.logged_in = a.get("data-logged-in")
elif tag == "meta" and a.get("property") == "og:title":
self.og_title = a.get("content") or ""
elif tag == "meta" and a.get("property") == "og:image":
self.og_image = image_url(a.get("content") or "")
elif tag == "a" and "tagItem" in cls:
self._in_tag, self._tag_buf = True, ""
if self._done:
@@ -80,6 +164,11 @@ 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:
for key in ("data-src", "src"):
if candidate := image_url(a.get(key) or ""):
self.post_image = candidate
break
elif tag == "br" and not self._skip:
self.post.append("\n")
elif tag in ("script", "style"):
@@ -109,13 +198,17 @@ class _Parser(HTMLParser):
def parse(html: str) -> dict:
p = _Parser()
p.feed(html)
if p.template == "login" or not p.post:
raise F95Error("Тема не найдена или закрыта для гостей")
if p.template == "login":
raise F95AuthError("F95zone запрашивает авторизацию")
if not p.post:
raise F95Error("Тема не найдена или недоступна")
title = unescape(p.og_title)
# "VN - Ren'Py - Hooked! [v0.6.0] [BlenderKnight]" -> "Hooked!"
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))}
return {"title": title, "description": _description("".join(p.post)),
"tags": sorted(set(p.tags)), "image_url": p.og_image or p.post_image,
"logged_in": p.logged_in}
def _description(text: str) -> str:
@@ -139,6 +232,6 @@ def _description(text: str) -> str:
return re.sub(r"\n{3,}", "\n\n", "\n".join(out)).strip()
def load(url: str) -> dict:
def load(url: str, cookie: str = "", user_agent: str = "") -> dict:
url = validate_url(url)
return {**parse(fetch(url)), "url": url}
return {**parse(fetch(url, cookie, user_agent)), "url": url}