"""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")