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>
This commit is contained in:
parent
85ee42d5a3
commit
7a8e10c175
4 changed files with 245 additions and 0 deletions
|
|
@ -49,6 +49,7 @@ from app.connectors.direct_scrapers.pornbusy import PornBusyScraper
|
|||
from app.connectors.direct_scrapers.pornmike import PornMikeScraper
|
||||
from app.connectors.direct_scrapers.kporner8 import KPorner8Scraper
|
||||
from app.connectors.direct_scrapers.hentaimama import HentaiMamaScraper
|
||||
from app.connectors.direct_scrapers.xanimeporn import XAnimePornScraper
|
||||
from app.connectors.direct_scrapers.fullporner import FullPornerScraper
|
||||
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
|
||||
from app.connectors.direct_scrapers.youperv import YoupervScraper
|
||||
|
|
@ -199,6 +200,13 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
|||
# Motyw z rodziny Dooplay, ale DooplayConnector NIE pasuje (brak data-fl-source).
|
||||
# Playback: extractor `hentaimama`, POST admin-ajax za Cloudflare → Bright Data.
|
||||
HentaiMamaScraper,
|
||||
# XAnimePornScraper — dodany 2026-08-04, drugie źródło wertykału hentai.
|
||||
# NIE re-host hentaimamy: wspólny odcinek (Shoujo Ramune 7) leży na obu, ale to inne
|
||||
# pliki — tu jest jawny wybór 1080p (222 MB) / 720p (84 MB), tam jedna jakość bez
|
||||
# etykiety. Paginacja strony głównej ZEPSUTA (str. 2-3 = str. 1, jak przy 8kporner),
|
||||
# więc listing z dwóch archiwów: /uncensored/ i /censored/, które stronicują poprawnie.
|
||||
# Playback bez Cloudflare i bez proxy, w odróżnieniu od hentaimamy.
|
||||
XAnimePornScraper,
|
||||
# FullPornerScraper, dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
|
||||
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
|
||||
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
|
||||
|
|
|
|||
172
app/connectors/direct_scrapers/xanimeporn.py
Normal file
172
app/connectors/direct_scrapers/xanimeporn.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""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,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
@ -38,6 +38,7 @@ from app.extractors.tubes import (
|
|||
hqporner,
|
||||
fullporner,
|
||||
hentaimama,
|
||||
xanimeporn,
|
||||
kporner8,
|
||||
sexu,
|
||||
javflix,
|
||||
|
|
@ -135,6 +136,10 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
|||
# wymaga Bright Data) → strona playera → bezpośredni mp4. Preferujemy gdvid,
|
||||
# bo oddaje plik bez tokenu, więc URL nie wygasa. Portable, mobile_direct_ok.
|
||||
"hentaimama": hentaimama.extract,
|
||||
# xanimeporn — Fluid Player z PUSTYM <source>, wideo wstrzykiwane w runtime.
|
||||
# Wyjście: zakładka Downloads, czyli videos.xanimeporn.com/download.php?id=<postId>.
|
||||
# Oddajemy download.php, nie docelowy plik, żeby token generował się przy odtwarzaniu.
|
||||
"xanimeporn": xanimeporn.extract,
|
||||
# fullporner, iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości.
|
||||
# Zero tokenu i zero wygasania, URL składamy sami. Blokada jest na nagłówku (bez
|
||||
# UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi.
|
||||
|
|
|
|||
60
app/extractors/tubes/xanimeporn.py
Normal file
60
app/extractors/tubes/xanimeporn.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""xanimeporn.com — mp4 z endpointu pobierania. Dodany 2026-08-04.
|
||||
|
||||
Player to Fluid Player z **pustym `<source src=''>`** — źródło wstrzykiwane jest w
|
||||
runtime, a skrypt inicjalizujący obsługuje wyłącznie fullscreen i analitykę. W statycznym
|
||||
HTML nie ma ani jednego linku do wideo, więc wygląda to na ślepy zaułek.
|
||||
|
||||
Wyjściem jest zakładka „Downloads", która niesie:
|
||||
|
||||
https://videos.xanimeporn.com/download.php?id=<postId>&quality=1080p
|
||||
https://videos.xanimeporn.com/download.php?id=<postId>&quality=720p
|
||||
|
||||
`postId` siedzi w klasie `<body>` (`postid-69951`), więc nie trzeba parsować zakładki —
|
||||
składamy URL sami dla obu jakości.
|
||||
|
||||
**Oddajemy `download.php`, a NIE docelowego pliku.** Endpoint przekierowuje na
|
||||
`download.xanimeporn.com/...?s=<sig>&e=<epoch>`, czyli URL z tokenem i wygasaniem.
|
||||
Zwracając `download.php` zostawiamy generowanie tokenu na moment odtwarzania: telefon
|
||||
idzie za przekierowaniem i dostaje świeży podpis, więc nic nie wietrzeje. To ta sama
|
||||
lekcja co przy watchporn, gdzie zapisany statyczny URL po czasie padał.
|
||||
|
||||
Zweryfikowane z VPS-a: 1080p 222 MB i 720p 84 MB, oba 206 na Range z `ftypisom`/`ftypmp42`
|
||||
w pierwszym KB (faststart). Wymaga Referera i UA, oba dokłada `playback.py`. Bez
|
||||
Cloudflare i bez proxy, w odróżnieniu od hentaimamy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.extractors._fetch import fetch_tube_html
|
||||
from app.extractors._models import StreamSource
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_BASE = "https://xanimeporn.com"
|
||||
_POST_ID_RE = re.compile(r"\bpostid-(\d+)\b")
|
||||
#: Serwis publikuje dokładnie te dwie jakości; kolejność = preferencja.
|
||||
_QUALITIES = ("1080p", "720p")
|
||||
|
||||
|
||||
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
|
||||
html_text = fetch_tube_html(page_url, timeout=timeout)
|
||||
m = _POST_ID_RE.search(html_text)
|
||||
if not m:
|
||||
log.info("xanimeporn: brak postid w klasie body na %s", page_url)
|
||||
return None
|
||||
post_id = m.group(1)
|
||||
|
||||
return [
|
||||
StreamSource(
|
||||
link=f"https://videos.xanimeporn.com/download.php?id={post_id}&quality={q}",
|
||||
type="mp4",
|
||||
quality=q,
|
||||
referer=_BASE + "/",
|
||||
# Zweryfikowane 206 + faststart z VPS-a; telefon sam podąża za 302 i
|
||||
# dostaje własny, świeży token.
|
||||
raw={"mobile_direct_ok": True},
|
||||
)
|
||||
for q in _QUALITIES
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue