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:
1 parent
ac164d1782
commit
ae8e175376
5 files changed
+227
No files matched your search
@@ -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}
|
||||||
@@ -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))
|
||||||
@@ -4,6 +4,7 @@ import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../ap
|
|||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import { getRecent, pushRecent } from "../recent";
|
import { getRecent, pushRecent } from "../recent";
|
||||||
import { Cover, ErrorNote, Spinner } from "../ui/Bits";
|
import { Cover, ErrorNote, Spinner } from "../ui/Bits";
|
||||||
|
import { F95Fetch } from "../ui/F95Fetch";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
import { useUi } from "../ui/Ui";
|
import { useUi } from "../ui/Ui";
|
||||||
|
|
||||||
@@ -358,6 +359,11 @@ function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: bool
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<form className="editor" onSubmit={(e) => { e.preventDefault(); void save(); }}>
|
<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><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><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>
|
<label className="field"><span>Теги</span><input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="через запятую: романтика, драма" /></label>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, type DragEvent, type FormEvent } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { api, formatSize, uploadRaw, type Game } from "../api";
|
import { api, formatSize, uploadRaw, type Game } from "../api";
|
||||||
import { ErrorNote, PageHeader } from "../ui/Bits";
|
import { ErrorNote, PageHeader } from "../ui/Bits";
|
||||||
|
import { F95Fetch } from "../ui/F95Fetch";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
|
|
||||||
const ARCHIVE = /\.(zip|7z|rar|tar|gz|tgz|bz2|xz)$/i;
|
const ARCHIVE = /\.(zip|7z|rar|tar|gz|tgz|bz2|xz)$/i;
|
||||||
@@ -80,6 +81,11 @@ export function NewGame() {
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<F95Fetch onLoaded={(m) => {
|
||||||
|
setTitle(m.title);
|
||||||
|
setDescription(m.description);
|
||||||
|
setTags(m.tags.join(", "));
|
||||||
|
}} />
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Название</span>
|
<span>Название</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user