feat(hentai): konektor hentaiplay.net - czwarte zrodlo wertykalu

CZWARTY niezalezny host: pliki na hentaiplanet.info, podczas gdy hentaimama ma
gdvid.info, xanimeporn download.xanimeporn.com, a hentaigasm hgasm<N>.com. Cztery
strony, cztery osobne pule plikow - zadna nie re-hostuje pozostalych, mimo ze katalogi
odcinkow mocno sie pokrywaja.

Metadane z RSS-a, tak jak przy hentaigasm i z tego samego powodu: /genre/ w HTML strony
to 2084 linki, najwieksza chmura z sidebara w calym wertykale (hentaigasm mial 38,
hentaimama 118). Feed daje date, tytul i realne kategorie. Odsiewamy przy tym pozycje
czysto techniczne (Hentai Anime, New Hentai, English Subbed) oraz rocznik, bo to nie sa
tagi tresci. /page/2/ nie zwraca odcinkow, /feed/?paged=N stronicuje chronologicznie.

Playback: mp4 wprost w HTML (video.js), bez AJAX-a i bez Cloudflare. Pulapka: serwis
nazywa miniaturki od pliku wideo (…-RAW.mp4_snapshot_07.59.jpg), wiec naiwne szukanie
.mp4 lapie zarowno wideo, jak i obrazek. Wymagamy URL-a KONCZACEGO sie na .mp4 i
odrzucamy wp-content/uploads.

Druga pulapka, zlapana dopiero testem: og:image na tej stronie NIE ISTNIEJE, miniaturka
jest tylko w JSON-LD i z escapowanymi ukosnikami. Pierwsza wersja dawala 12/12 odcinkow
bez miniaturki.

Zweryfikowane z VPS: 206 na Range, ftypmp42, 78 MB, dziala BEZ Referera i bez tokenu.
Serwer oddaje content-type application/octet-stream, ale sciezka konczy sie .mp4, wiec
ExoPlayer wybierze Mp4Extractor po rozszerzeniu.

Pilot: seen 48, new 48, errors 0. Komplet dat i miniaturek.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-08-12 15:31:21 +02:00
parent a5295be10e
commit fcfac697cf
4 changed files with 198 additions and 0 deletions

View file

@ -50,6 +50,7 @@ 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.hentaigasm import HentaiGasmScraper
from app.connectors.direct_scrapers.hentaiplay import HentaiPlayScraper
from app.connectors.direct_scrapers.xanimeporn import XAnimePornScraper
from app.connectors.direct_scrapers.fullporner import FullPornerScraper
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
@ -213,6 +214,10 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
# daty w zadnym standardowym miejscu, a /genre/ to 38 linkow chmury z sidebara.
# Playback najprostszy z calej trojki: JWPlayer z gotowym file: wprost w HTML.
HentaiGasmScraper,
# HentaiPlayScraper — dodany 2026-08-04, CZWARTY niezalezny host wertykalu
# (hentaiplanet.info). Metadane z RSS-a, bo /genre/ w HTML to 2084 linki chmury,
# najwiecej w calym wertykale. Playback: mp4 wprost w HTML, bez CF i bez proxy.
HentaiPlayScraper,
# 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.

View file

@ -0,0 +1,131 @@
"""hentaiplay.net — browse scraper. Czwarte źródło wertykału hentai. Dodany 2026-08-04.
**Czwarty niezależny host w wertykale**: pliki leżą na `hentaiplanet.info`, podczas gdy
[[hentaimama]] ma `gdvid.info`, [[xanimeporn]] `download.xanimeporn.com`, a
[[hentaigasm]] `hgasm<N>.com`. Cztery strony, cztery osobne pule plików — żadna nie
re-hostuje pozostałych, mimo że katalogi odcinków mocno się pokrywają.
Kryterium jak w całym wertykale: dla animacji nie istnieje kanon, więc każda pozycja
jest orphanem i to poprawne. Bez obsady i bez canonical matcha.
**Metadane z RSS-a**, tak samo jak przy hentaigasm i z tego samego powodu: `/genre/`
w HTML strony to **2084 linki**, największa chmura z sidebara w całym wertykale
(dla porównania hentaigasm 38, hentaimama 118). Feed daje 6 realnych kategorii na
odcinek plus datę i tytuł. `/page/2/` nie zwraca odcinków, a `/feed/?paged=N`
stronicuje chronologicznie.
Strona odcinka jest potrzebna tylko do miniaturki; wideo wyciąga extractor `hentaiplay`.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from email.utils import parsedate_to_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://hentaiplay.net"
_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.DOTALL)
_LINK_RE = re.compile(r"<link>\s*(https?://hentaiplay\.net/[^<\s]+?)\s*</link>")
_TITLE_RE = re.compile(r"<title>(.*?)</title>", re.DOTALL)
_PUBDATE_RE = re.compile(r"<pubDate>(.*?)</pubDate>", re.DOTALL)
_CATEGORY_RE = re.compile(r"<category><!\[CDATA\[(.*?)\]\]></category>", re.DOTALL)
# Miniaturka jest TYLKO w JSON-LD i z escapowanymi ukośnikami (`https:\/\/…`).
# `og:image` na tej stronie nie istnieje — pierwsza wersja scrapera dawała przez to
# 12/12 odcinków bez miniaturki.
_THUMB_RE = re.compile(r'"thumbnailUrl"\s*:\s*\[?\s*"([^"]{10,300})"')
#: Kategorie czysto techniczne — nie niosą nic o treści odcinka.
_SKIP_CATS = frozenset({"hentai anime", "new hentai", "english subbed", "raw"})
class HentaiPlayScraper(BaseBrowseScraper):
sitetag = "hentaiplay"
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(_LINK_RE.findall(listing_html)))
def crawl_page(self, page: int) -> list[RawScene] | None:
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
items = _ITEM_RE.findall(res.text)
if not items:
return []
out: list[RawScene] = []
for item in items:
lm = _LINK_RE.search(item)
tm = _TITLE_RE.search(item)
if not lm or not tm:
continue
url = lm.group(1).strip()
title = html_mod.unescape(tm.group(1)).strip()
if not title:
continue
release_date = None
dm = _PUBDATE_RE.search(item)
if dm:
try:
release_date = parsedate_to_datetime(dm.group(1).strip()).date()
except (TypeError, ValueError):
pass
tags: list[RawTag] = []
seen: set[str] = set()
for raw_cat in _CATEGORY_RE.findall(item):
name = html_mod.unescape(raw_cat).strip()
if name.lower() in _SKIP_CATS or name.isdigit():
continue # „Hentai Anime"/„New Hentai"/rocznik nie są tagami treści
slug = slugify(name)
if slug and slug not in seen:
seen.add(slug)
tags.append(
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
)
thumb = None
try:
det = browser_get(url, timeout=self._timeout)
det.raise_for_status()
im = _THUMB_RE.search(det.text)
if im:
thumb = im.group(1).strip().replace("\\/", "/")
except Exception as e:
log.info("%s: miniaturka fail %s: %s", self.sitetag, url, e)
slug = url.rstrip("/").rsplit("/", 1)[-1]
out.append(
RawScene(
external_id=f"{self.sitetag}:{slug}",
title=title,
release_date=release_date,
url=url,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=url,
thumbnail_url=thumb,
)
],
)
)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: # pragma: no cover
raise NotImplementedError("hentaiplay buduje sceny z RSS-a w crawl_page")

View file

@ -38,6 +38,7 @@ from app.extractors.tubes import (
hqporner,
fullporner,
hentaigasm,
hentaiplay,
hentaimama,
xanimeporn,
kporner8,
@ -141,6 +142,10 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
# bez AJAX-a i bez CF. Sciezka ma SPACJE, trzeba zakodowac. Bez tokenu, bez
# Referera, 206 z VPS → mobile_direct_ok.
"hentaigasm": hentaigasm.extract,
# hentaiplay — mp4 wprost w HTML (video.js). Wymagamy URL-a KONCZACEGO sie na .mp4
# i odrzucamy wp-content/uploads, bo serwis nazywa miniaturki od pliku wideo
# (…-RAW.mp4_snapshot_07.59.jpg) i naiwne .mp4 lapaloby oba.
"hentaiplay": hentaiplay.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.

View file

@ -0,0 +1,57 @@
"""hentaiplay.net — bezpośredni mp4 z odtwarzacza video.js. Dodany 2026-08-04.
Link do pliku siedzi wprost w HTML strony odcinka (player to video.js z
`vjs.zencdn.net`), bez AJAX-a i bez Cloudflare — podobnie jak przy [[hentaigasm]],
w odróżnieniu od [[hentaimama]] (POST za CF) i [[xanimeporn]] (pusty `<source>`).
**Trzeba odsiać miniaturki, nie tylko szukać `.mp4`.** Serwis nazywa zrzuty klatek
od pliku wideo, więc w HTML leżą obok siebie:
https://hentaiplanet.info/Ushiro-no-Shoumen-Kamui-san-Episode-4-RAW.mp4 ← wideo
…/wp-content/uploads/Ushiro-…-RAW.mp4_snapshot_07.59.296-160x90.jpg ← miniaturka
Naiwne `\\.mp4` łapie oba. Dlatego wymagamy, żeby URL KOŃCZYŁ się na `.mp4`, i dodatkowo
odrzucamy `wp-content/uploads` (miniaturki serwisu).
Zweryfikowane z VPS-a: 206 na Range, `ftypmp42` w pierwszym KB (faststart), 78 MB,
działa BEZ Referera i bez tokenu. Serwer oddaje `content-type: application/octet-stream`,
ale ścieżka kończy się `.mp4`, więc ExoPlayer i tak wybierze `Mp4Extractor` po
rozszerzeniu. Stąd `mobile_direct_ok`.
"""
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://hentaiplay.net"
#: Tylko URL-e KOŃCZĄCE się na .mp4 — patrz pułapka z miniaturkami w docstringu.
_MP4_RE = re.compile(r"https?://[A-Za-z0-9._~%+-]+(?:/[A-Za-z0-9._~%+-]+)*\.mp4(?=[\"'\s<>]|$)")
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html_text = fetch_tube_html(page_url, timeout=timeout)
seen: set[str] = set()
out: list[StreamSource] = []
for link in _MP4_RE.findall(html_text):
if "wp-content/uploads" in link or link in seen:
continue
seen.add(link)
out.append(
StreamSource(
link=link,
type="mp4",
quality=None, # serwis publikuje jedną rendycję, bez etykiety
referer=_BASE + "/",
# Zweryfikowane: 206 bez Referera, bez tokenu, faststart.
raw={"mobile_direct_ok": True},
)
)
if not out:
log.info("hentaiplay: brak linku mp4 na %s", page_url)
return None
return out