From fcfac697cfd6adc2c2f648cc3a3e5c1810e04ad2 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Wed, 12 Aug 2026 15:31:21 +0200 Subject: [PATCH] feat(hentai): konektor hentaiplay.net - czwarte zrodlo wertykalu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CZWARTY niezalezny host: pliki na hentaiplanet.info, podczas gdy hentaimama ma gdvid.info, xanimeporn download.xanimeporn.com, a hentaigasm hgasm.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 --- app/connectors/direct_scrapers/__init__.py | 5 + app/connectors/direct_scrapers/hentaiplay.py | 131 +++++++++++++++++++ app/extractors/__init__.py | 5 + app/extractors/tubes/hentaiplay.py | 57 ++++++++ 4 files changed, 198 insertions(+) create mode 100644 app/connectors/direct_scrapers/hentaiplay.py create mode 100644 app/extractors/tubes/hentaiplay.py diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index 4e1edd8..10d06da 100644 --- a/app/connectors/direct_scrapers/__init__.py +++ b/app/connectors/direct_scrapers/__init__.py @@ -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. diff --git a/app/connectors/direct_scrapers/hentaiplay.py b/app/connectors/direct_scrapers/hentaiplay.py new file mode 100644 index 0000000..8d7c619 --- /dev/null +++ b/app/connectors/direct_scrapers/hentaiplay.py @@ -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.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"(.*?)", re.DOTALL) +_LINK_RE = re.compile(r"\s*(https?://hentaiplay\.net/[^<\s]+?)\s*") +_TITLE_RE = re.compile(r"(.*?)", re.DOTALL) +_PUBDATE_RE = re.compile(r"(.*?)", re.DOTALL) +_CATEGORY_RE = re.compile(r"", 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") diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py index 02d47d2..e8eede5 100644 --- a/app/extractors/__init__.py +++ b/app/extractors/__init__.py @@ -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 , wideo wstrzykiwane w runtime. # Wyjście: zakładka Downloads, czyli videos.xanimeporn.com/download.php?id=. # Oddajemy download.php, nie docelowy plik, żeby token generował się przy odtwarzaniu. diff --git a/app/extractors/tubes/hentaiplay.py b/app/extractors/tubes/hentaiplay.py new file mode 100644 index 0000000..51e162e --- /dev/null +++ b/app/extractors/tubes/hentaiplay.py @@ -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 ``). + +**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