Pull title, description and tags from an F95zone thread

Admin/uploader pastes a thread link and presses "Подтянуть" on the new
game and edit forms; the backend reads the public first post (no login).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 03:57:28 +03:00
1 parent ac164d1782
commit ae8e175376
5 files changed
+227

No files matched your search

+142
View File
@@ -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 <title>-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}
+29
View File
@@ -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))
+6
View File
@@ -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>
+6
View File
@@ -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>
+44
View File
@@ -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>
);
}