diff --git a/backend/app/f95.py b/backend/app/f95.py
new file mode 100644
index 0000000..8d6d55e
--- /dev/null
+++ b/backend/app/f95.py
@@ -0,0 +1,142 @@
+"""Reads title, description and tags from a public F95zone thread (no login, first post only)."""
+import re
+import urllib.error
+import urllib.request
+from html import unescape
+from html.parser import HTMLParser
+from urllib.parse import 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
+# 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")
+
+
+class F95Error(Exception):
+ 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"):
+ raise F95Error("Ссылка должна вести на тему f95zone.to")
+ if not THREAD_RE.match(p.path):
+ raise F95Error("Это не ссылка на тему (нужен вид f95zone.to/threads/…)")
+ 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"})
+ try:
+ with urllib.request.urlopen(req, timeout=15) as r: # noqa: S310 - host is validated
+ final = urlparse(r.geturl())
+ if (final.hostname or "") not in ("f95zone.to", "www.f95zone.to"):
+ raise F95Error("Неожиданный редирект")
+ return r.read(MAX_BYTES).decode("utf-8", "replace")
+ except F95Error:
+ raise
+ except urllib.error.HTTPError as e:
+ raise F95Error(f"F95zone ответил {e.code} (тема недоступна или сайт блокирует запрос)")
+ except OSError:
+ raise F95Error("Не удалось связаться с F95zone")
+
+
+class _Parser(HTMLParser):
+ """Collects
-less data: og:title, tag items and the text of the first .bbWrapper."""
+
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.og_title = ""
+ self.tags: list[str] = []
+ self.post: list[str] = []
+ self.template = ""
+ self._in_tag = False
+ self._tag_buf = ""
+ self._depth = 0 # div depth inside the first bbWrapper; 0 = outside
+ self._done = False
+ self._skip = 0 # inside hidden/spoiler blocks
+
+ def handle_starttag(self, tag, attrs):
+ a = dict(attrs)
+ cls = (a.get("class") or "").split()
+ if tag == "html":
+ self.template = a.get("data-template") or ""
+ elif tag == "meta" and a.get("property") == "og:title":
+ self.og_title = a.get("content") or ""
+ elif tag == "a" and "tagItem" in cls:
+ self._in_tag, self._tag_buf = True, ""
+ if self._done:
+ return
+ if self._depth == 0:
+ if tag == "div" and "bbWrapper" in cls and not self.post:
+ self._depth = 1
+ return
+ if tag == "div":
+ self._depth += 1
+ if self._skip or {"bbCodeSpoiler", "messageHide"} & set(cls):
+ self._skip += 1
+ elif tag == "br" and not self._skip:
+ self.post.append("\n")
+ elif tag in ("script", "style"):
+ self._skip += 1
+
+ def handle_endtag(self, tag):
+ if tag == "a" and self._in_tag:
+ self._in_tag = False
+ if t := self._tag_buf.strip().lower():
+ self.tags.append(t)
+ if self._depth and tag == "div":
+ if self._skip:
+ self._skip -= 1
+ self._depth -= 1
+ if self._depth == 0:
+ self._done = True
+ elif self._depth and tag in ("script", "style") and self._skip:
+ self._skip -= 1
+
+ def handle_data(self, data):
+ if self._in_tag:
+ self._tag_buf += data
+ if self._depth and not self._skip and not self._done:
+ self.post.append(data)
+
+
+def parse(html: str) -> dict:
+ p = _Parser()
+ p.feed(html)
+ if p.template == "login" or 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))}
+
+
+def _description(text: str) -> str:
+ text = text.replace("", "").replace("\xa0", " ")
+ lines = [ln.strip() for ln in text.splitlines()]
+ out: list[str] = []
+ started = False
+ for ln in lines:
+ label = ln.split(":", 1)[0].strip().lower() if ":" in ln else ""
+ if label in STOP_LABELS and started:
+ break
+ if label == "overview":
+ started = True
+ ln = ln.split(":", 1)[1].strip()
+ if not ln:
+ continue
+ elif not started and label in STOP_LABELS:
+ continue
+ started = started or bool(ln)
+ out.append(ln)
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(out)).strip()
+
+
+def load(url: str) -> dict:
+ url = validate_url(url)
+ return {**parse(fetch(url)), "url": url}
diff --git a/backend/app/routers/metadata.py b/backend/app/routers/metadata.py
new file mode 100644
index 0000000..4f584b9
--- /dev/null
+++ b/backend/app/routers/metadata.py
@@ -0,0 +1,29 @@
+from fastapi import APIRouter, HTTPException, status
+from pydantic import BaseModel
+from starlette.concurrency import run_in_threadpool
+
+from app import f95
+from app.deps import CurrentUser, has_perm
+
+router = APIRouter(prefix="/api/metadata", tags=["metadata"])
+
+
+class F95In(BaseModel):
+ url: str
+
+
+class MetaOut(BaseModel):
+ url: str
+ title: str
+ description: str
+ tags: list[str]
+
+
+@router.post("/f95", response_model=MetaOut)
+async def from_f95(body: F95In, user: CurrentUser) -> dict:
+ if not (has_perm(user, "games.upload") or has_perm(user, "games.edit")):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав")
+ try:
+ return await run_in_threadpool(f95.load, body.url)
+ except f95.F95Error as e:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(e))
diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx
index 0047e42..15b0319 100644
--- a/frontend/src/pages/GamePage.tsx
+++ b/frontend/src/pages/GamePage.tsx
@@ -4,6 +4,7 @@ import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../ap
import { useAuth } from "../auth";
import { getRecent, pushRecent } from "../recent";
import { Cover, ErrorNote, Spinner } from "../ui/Bits";
+import { F95Fetch } from "../ui/F95Fetch";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
@@ -358,6 +359,11 @@ function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: bool
};
return (