"""hentaimama.io \u2014 browse scraper. Dodany 2026-08-04. Pierwsze źródło wertykału hentai. **Ten wertykał rządzi się inaczej niż reszta i to jest celowe.** Dla hentai NIE ISTNIEJE kanon: TPDB i StashDB nie mają animacji ani postaci rysowanych, więc każda scena stąd jest technicznie orphanem i to jest POPRAWNE, nie błędne. Kryterium „fabryka orphanów", którym oceniamy tuby z żywymi aktorami, tutaj po prostu nie ma zastosowania \u2014 nie ma też obsady, więc żadnej bramki na performera. Miarą jakości jest komplet seria + numer odcinka + gatunki, a docelowo dedup MIĘDZY stronami hentai (re-hostują się nawzajem). Jednostka treści to ODCINEK (`/episodes/-episode-N/`), nie seria. Każdy jest samodzielną pozycją z własnym plakatem i playbackiem, więc mapuje się na scenę tak samo jak w wertykale JAV \u2014 bez zmian w schemacie. Motyw jest z rodziny Dooplay, ale `DooplayConnector` się NIE nadaje: nie ma tu `data-fl-source` ani `data-post`, na których stoi tamten parser. Player idzie przez `POST /wp-admin/admin-ajax.php` i jest za Cloudflare \u2014 obsługuje to extractor `hentaimama`, patrz jego docstring. Pułapka: `/genre/` w całym HTML to 118 linków (chmura w sidebarze, ten sam wzorzec co przy sexu i yourdailypornmovies). Gatunki bierzemy WYŁĄCZNIE z `
`, co daje realne 9 pozycji na odcinek zamiast 118 śmieci. Strona nie podaje długości odcinka. """ 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.normalize.text import slugify log = logging.getLogger(__name__) _BASE = "https://hentaimama.io" _EPISODE_HREF_RE = re.compile(r'href="(https://hentaimama\.io/episodes/[^"]+)"') # Sam listing `/episodes/` i jego paginacja też pasują do wzorca wyżej \u2014 bez tego # filtra wchodziły jako „odcinek" o tytule „Recent Episodes". # `/episodes/feed/` (RSS) tez wpada w href-regex i konczyl jako pusty detail z None. _NOT_EPISODE_RE = re.compile(r"/episodes/(?:page/\d+/|feed/)?$") # `og:title` zamiast

: na stronie są trzy

, dwa to logo serwisu, a trzeci bywa # nagłówkiem sekcji. og:title daje czysty „Shoujo Ramune Episode 7". _OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,150})") # Gatunki TYLKO stąd \u2014 patrz pułapka w docstringu. _GENRES_BLOCK_RE = re.compile(r'
(.*?)
', re.DOTALL) _ANCHOR_TEXT_RE = re.compile(r">\s*([^<]{2,40}?)\s*") _DATE_RE = re.compile(r'\s*([A-Z][a-z]{2}\.?\s+\d{1,2},\s+\d{4})') # Dlugosc odcinka w sekundach siedzi w konfiguracji skryptu historii motywu dooplay: # `var dtHist = {..., "episode":"77667", "duration":"953", ...}`. Bez niej 100% scen # mialo NULL (audyt 2026-10-14), a apka filtruje min 60 s. _DT_DURATION_RE = re.compile(r'"duration"\s*:\s*"(\d{2,6})"') _SERIES_RE = re.compile(r'href="https://hentaimama\.io/tvshows/([a-z0-9-]+)/"') # Uwaga: atrybuty bywają w innej kolejności niż ``, # a wartość ma na końcu \r \u2014 stąd luźny wzorzec i strip() przy użyciu. _TITLE_TAIL_RE = re.compile( r"(?:\s*[\u2013\u2014|-]\s*Watch\s+Online\s+Free\s+in\s+HD)?\s*[\u2013\u2014|-]\s*Hentaimama\s*$", re.IGNORECASE, ) _OG_IMAGE_RE = re.compile(r"property=['\"]og:image['\"][^>]*content=['\"]([^'\"]+)") def _parse_date(value: str | None): if not value: return None for fmt in ("%b. %d, %Y", "%b %d, %Y"): try: return datetime.strptime(value.strip(), fmt).date() except ValueError: continue return None class HentaiMamaScraper(BaseBrowseScraper): sitetag = "hentaimama" def _listing_url(self, page: int) -> str: return f"{_BASE}/episodes/" if page <= 1 else f"{_BASE}/episodes/page/{page}/" def _extract_scene_urls(self, listing_html: str) -> list[str]: return [ u for u in dict.fromkeys(_EPISODE_HREF_RE.findall(listing_html)) if not _NOT_EPISODE_RE.search(u) ] def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: tm = _OG_TITLE_RE.search(detail_html) title = html_mod.unescape(tm.group(1)).strip() if tm else "" # Od ~31.08.2026 og:title ma ogon "TYTUL - Watch Online Free in HD - Hentaimama" # (z polpauzami). Ucinamy go, inaczej tytul nie laczy sie z ta sama scena z innych stron. title = _TITLE_TAIL_RE.sub("", title).strip() if not title or title.lower() == "hentaimama": return None tags: list[RawTag] = [] seen: set[str] = set() gb = _GENRES_BLOCK_RE.search(detail_html) if gb: for name in _ANCHOR_TEXT_RE.findall(gb.group(1)): name = html_mod.unescape(name).strip() 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) ) # Seria jako tag \u2014 pozwala grupować odcinki bez zmian w schemacie. sm = _SERIES_RE.search(detail_html) if sm and sm.group(1) not in seen: series = sm.group(1).replace("-", " ").title() tags.append( RawTag( external_id=f"{self.sitetag}:series:{sm.group(1)}", name=series, slug=slugify(series), ) ) dm = _DATE_RE.search(detail_html) om = _OG_IMAGE_RE.search(detail_html) slug = scene_url.rstrip("/").rsplit("/", 1)[-1] dur_m = _DT_DURATION_RE.search(detail_html) duration_sec = int(dur_m.group(1)) if dur_m and int(dur_m.group(1)) > 0 else None return RawScene( external_id=f"{self.sitetag}:{slug}", title=title, release_date=_parse_date(dm.group(1) if dm else None), duration_sec=duration_sec, url=scene_url, tags=tags, playback_sources=[ RawPlaybackSource( origin=f"tube:{self.sitetag}", page_url=scene_url, duration_sec=duration_sec, thumbnail_url=om.group(1).strip() if om else None, ) ], )