goon/app/connectors/direct_scrapers/xanimeporn.py
goon-foss 7a8e10c175 feat(hentai): konektor xanimeporn.com - drugie zrodlo wertykalu
NIE jest re-hostem hentaimamy i to bylo pierwsze, co sprawdzilem. Wspolny odcinek
(Shoujo Ramune 7) lezy na obu stronach, ale to zupelnie inne pliki: hentaimama serwuje
gdvid.info/S/shoujo-ramune-7.mp4 w jednej jakosci bez etykiety, a tutaj jest
download.xanimeporn.com z jawnym wyborem 1080p (222 MB) i 720p (84 MB).

Listing idzie z RSS-a i to jedyna droga, ktora dziala. Sprawdzone trzy inne:
- /page/2/ i /page/3/ oddaja ten sam zestaw co strona 1 (28 z 32 wspolnych),
  czyli paginacja zepsuta jak przy 8kporner
- /uncensored/ i /censored/ stronicuja poprawnie, ale NIE sa chronologiczne: pierwsze
  pozycje z 2024, zakres 2015-2026. Browse-latest nigdy nie zobaczylby nowego odcinka,
  czyli pulapka z freshporno. Pierwsza wersja konektora wlasnie tak dzialala i dopiero
  test na danych to pokazal
- /hentai-list/ ma caly katalog (1048) na jednej stronie, ale nie jest chronologiczny

Drugi filtr tez wymuszony przez dane: slugi gatunkow (/futanari/, /big-tits/) sa takimi
samymi slugami najwyzszego poziomu jak odcinki i wchodzily jako pozycje bez daty i
miniaturki. _parse_detail wymaga teraz postid w klasie body, ktory maja tylko
pojedyncze wpisy. Ten sam identyfikator jest potrzebny extractorowi.

Playback: Fluid Player z PUSTYM <source src=''>, wideo wstrzykiwane w runtime, wiec w
HTML nie ma zadnego linku. Wyjsciem jest zakladka Downloads:
videos.xanimeporn.com/download.php?id=<postId>&quality=. Oddajemy download.php, NIE
docelowy plik, zeby token generowal sie przy odtwarzaniu i nie wietrzal (lekcja z
watchporn). Bez Cloudflare i bez proxy, w odroznieniu od hentaimamy.

Pilot: seen 50, new 50, errors 0. Metadane: 100 procent z data, miniaturka, kategoria
i seria jako tagi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:48:13 +02:00

172 lines
7.2 KiB
Python

"""xanimeporn.com — browse scraper. Drugie źródło wertykału hentai. Dodany 2026-08-04.
**NIE jest re-hostem hentaimamy — sprawdzone na wspólnym odcinku.** Shoujo Ramune 7
leży na obu stronach, ale to zupełnie inne pliki: hentaimama serwuje
`gdvid.info/S/shoujo-ramune-7.mp4` (jedna jakość, bez etykiety), a tutaj jest
`download.xanimeporn.com/videos/1080p/[1080p]69951.mp4` z **jawnym wyborem 1080p (222 MB)
i 720p (84 MB)**. To realna przewaga jakościowa, nie duplikat.
Kryterium jak przy [[hentaimama]]: dla animacji nie istnieje kanon, więc każda pozycja
jest orphanem i to jest poprawne. Nie ma obsady, nie ma canonical matcha — jakość mierzy
komplet seria + odcinek + jakość pliku.
**Listing idzie z RSS-a i to jedyna droga, która działa.** Sprawdzone trzy inne i każda
ma inną wadę:
- `/page/2/`, `/page/3/` — oddają ten sam zestaw co strona 1 (28 z 32 wspólnych),
czyli paginacja jest ZEPSUTA, identycznie jak przy 8kporner
- `/uncensored/` i `/censored/` — stronicują poprawnie, ale NIE są chronologiczne:
pierwsze pozycje są z 2024, a zakres sięga 2015-2026. Browse-latest nigdy nie
zobaczyłby nowego odcinka, czyli dokładnie pułapka z freshporno
- `/hentai-list/` — cały katalog (1048 pozycji) na jednej stronie, dobre do
przyszłego deep-crawlu, bezużyteczne do „najnowszych"
`/feed/` i `/feed/?paged=N` są chronologiczne (strona 1 = dziś, strona 2 = miesiąc
wstecz) i dają po 10 pozycji, co przy kilku odcinkach dziennie w zupełności wystarcza.
**Drugi filtr, też wymuszony przez dane:** slugi gatunków (`/futanari/`, `/big-tits/`,
`/betrayal/`) są takimi samymi slugami najwyższego poziomu jak odcinki i przechodziły
przez listę nazw nawigacji. Dlatego `_parse_detail` wymaga `postid-<n>` w klasie
`<body>` — mają go tylko pojedyncze wpisy, nie archiwa. Ten sam identyfikator jest
zresztą potrzebny extractorowi, więc brak = pozycja i tak bezużyteczna.
Playback: `videos.xanimeporn.com/download.php?id=<postId>&quality=…` (extractor
`xanimeporn`). Bez Cloudflare i bez proxy, w odróżnieniu od hentaimamy.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import datetime
from app.connectors.base import RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://xanimeporn.com"
_FEED_LINK_RE = re.compile(r"<link>(https://xanimeporn\.com/[a-z0-9][a-z0-9-]+/)</link>")
#: Tylko pojedyncze wpisy mają `postid-<n>` w klasie <body>; archiwa gatunków nie.
_POST_ID_RE = re.compile(r"\bpostid-(\d+)\b")
#: Kategoria z RSS-a — na stronie odcinka oba linki są w menu, więc czytanie ich
#: stamtąd dałoby ZAWSZE „censored" i „uncensored" naraz.
_FEED_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.DOTALL)
_FEED_CATEGORY_RE = re.compile(r"<category><!\[CDATA\[([^\]]+)\]\]></category>")
_OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,200})")
_PUBLISHED_RE = re.compile(
r"property=['\"]article:published_time['\"][^>]*content=['\"]([^'\"]+)"
)
_POSTER_RE = re.compile(r"posterImage:\s*'([^']+)'")
#: „Shoujo Ramune Episode 7 [Sub-ENG] | X Anime Porn" → tytuł bez sufiksu serwisu.
_TITLE_SUFFIX_RE = re.compile(r"\s*\|\s*X Anime Porn\s*$", re.IGNORECASE)
_SERIES_RE = re.compile(r"^(.*?)\s+Episode\s+\d+", re.IGNORECASE)
class XAnimePornScraper(BaseBrowseScraper):
sitetag = "xanimeporn"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/feed/" if page <= 1 else f"{_BASE}/feed/?paged={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return list(dict.fromkeys(_FEED_LINK_RE.findall(listing_html)))
def crawl_page(self, page: int) -> list[RawScene] | None:
"""Jak w bazie, ale kategorię (censored/uncensored) bierzemy z RSS-a, bo na
stronie odcinka oba linki siedzą w menu nawigacyjnym."""
try:
res = browser_get(self._listing_url(page), timeout=self._timeout)
res.raise_for_status()
except Exception as e:
log.warning("%s: feed strona %d fail: %s", self.sitetag, page, e)
return None
cats: dict[str, str] = {}
for item in _FEED_ITEM_RE.findall(res.text):
lm = _FEED_LINK_RE.search(item)
if not lm:
continue
for cat in _FEED_CATEGORY_RE.findall(item):
if cat.strip().lower() in ("censored", "uncensored"):
cats[lm.group(1)] = cat.strip().lower()
break
urls = self._extract_scene_urls(res.text)
if not urls:
return []
out: list[RawScene] = []
for url in urls:
try:
det = browser_get(url, timeout=self._timeout)
det.raise_for_status()
raw = self._parse_detail(url, det.text, cats.get(url, ""))
except Exception as e:
log.info("%s detail fail %s: %s", self.sitetag, url, e)
continue
if raw is not None:
out.append(raw)
return out
def _parse_detail( # type: ignore[override]
self, scene_url: str, detail_html: str, archive: str = ""
) -> RawScene | None:
# Archiwa gatunków (`/futanari/`, `/big-tits/`) mają takie same slugi jak odcinki
# i wchodziły jako pozycje bez daty i miniaturki. Tylko pojedynczy wpis ma postid.
if not _POST_ID_RE.search(detail_html):
return None
tm = _OG_TITLE_RE.search(detail_html)
if not tm:
return None
title = _TITLE_SUFFIX_RE.sub("", html_mod.unescape(tm.group(1))).strip()
if not title:
return None
release_date = None
pm = _PUBLISHED_RE.search(detail_html)
if pm:
try:
release_date = datetime.fromisoformat(pm.group(1)).date()
except ValueError:
pass
tags: list[RawTag] = []
if archive:
tags.append(
RawTag(
external_id=f"{self.sitetag}:tag:{archive}",
name=archive.capitalize(),
slug=archive,
)
)
# Seria jako tag — pozwala grupować odcinki, tak samo jak w hentaimamie.
sm = _SERIES_RE.match(title)
if sm and slugify(sm.group(1)):
series = sm.group(1).strip()
tags.append(
RawTag(
external_id=f"{self.sitetag}:series:{slugify(series)}",
name=series,
slug=slugify(series),
)
)
om = _POSTER_RE.search(detail_html)
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
return RawScene(
external_id=f"{self.sitetag}:{slug}",
title=title,
release_date=release_date,
url=scene_url,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
thumbnail_url=om.group(1).strip() if om else None,
)
],
)