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 ( <form className="editor" onSubmit={(e) => { e.preventDefault(); void save(); }}> + <F95Fetch onLoaded={(m) => { + setTitle(m.title); + setDescription(m.description); + setTags(m.tags.join(", ")); + }} /> <label className="field"><span>Название</span><input value={title} onChange={(e) => setTitle(e.target.value)} /></label> <label className="field"><span>Описание</span><textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} /></label> <label className="field"><span>Теги</span><input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="через запятую: романтика, драма" /></label> diff --git a/frontend/src/pages/NewGame.tsx b/frontend/src/pages/NewGame.tsx index da2a48d..6e2e9b6 100644 --- a/frontend/src/pages/NewGame.tsx +++ b/frontend/src/pages/NewGame.tsx @@ -2,6 +2,7 @@ import { useState, type DragEvent, type FormEvent } from "react"; import { useNavigate } from "react-router-dom"; import { api, formatSize, uploadRaw, type Game } from "../api"; import { ErrorNote, PageHeader } from "../ui/Bits"; +import { F95Fetch } from "../ui/F95Fetch"; import { Icon } from "../ui/Icon"; const ARCHIVE = /\.(zip|7z|rar|tar|gz|tgz|bz2|xz)$/i; @@ -80,6 +81,11 @@ export function NewGame() { )} </label> + <F95Fetch onLoaded={(m) => { + setTitle(m.title); + setDescription(m.description); + setTags(m.tags.join(", ")); + }} /> <div className="form-row"> <label className="field"> <span>Название</span> diff --git a/frontend/src/ui/F95Fetch.tsx b/frontend/src/ui/F95Fetch.tsx new file mode 100644 index 0000000..0690538 --- /dev/null +++ b/frontend/src/ui/F95Fetch.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import { api } from "../api"; + +export interface F95Meta { + url: string; + title: string; + description: string; + tags: string[]; +} + +/** Link to an F95zone thread + a button that pulls the title, description and tags from it. */ +export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) { + const [url, setUrl] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const load = async () => { + setBusy(true); + setError(""); + try { + onLoaded(await api.post<F95Meta>("/api/metadata/f95", { url })); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + + return ( + <div className="field"> + <span>Ссылка на тему F95zone</span> + <div style={{ display: "flex", gap: "0.5rem" }}> + <input value={url} onChange={(e) => setUrl(e.target.value)} style={{ flex: 1 }} + placeholder="https://f95zone.to/threads/…" + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (url.trim() && !busy) void load(); } }} /> + <button type="button" className="btn" disabled={!url.trim() || busy} onClick={() => void load()}> + {busy ? "Загрузка…" : "Подтянуть"} + </button> + </div> + <span className="hint">Заполнит название, описание и теги. Результат можно поправить перед сохранением.</span> + {error && <span className="hint" style={{ color: "var(--danger, #e5484d)" }}>{error}</span>} + </div> + ); +}