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>
131 lines
5.3 KiB
Python
131 lines
5.3 KiB
Python
"""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")
|