"""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 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"): 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, cookie: str = "", user_agent: str = "") -> str: req = _request(url, cookie=cookie, user_agent=user_agent) try: 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("Неожиданный редирект") return r.read(MAX_BYTES).decode("utf-8", "replace") 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} (тема недоступна или сайт блокирует запрос)") except OSError: raise F95Error("Не удалось связаться с F95zone") class _Parser(HTMLParser): """Collects