goon/app/connectors/direct_scrapers/hentaimama.py
goon-foss 45fbe0412e fix: hentaimama i xanime graja, filmy mangoporn z hosterami, ostatni link filmu chroniony
hentaimama: nowy zapis id posta i 5 playerow w admin-ajax, adresy w JSON-ie;
bierzemy mp4 z gdvid (3 jakosci, bez tokenu), POST najpierw bez proxy.
Ogon "Watch Online Free in HD" w tytulach ucinany.
xanimeporn: przekierowanie download.php rozwiazywane na serwerze, apka
dostawala .php, otwierala WebView i pobierala plik zamiast grac.
Blad resolve (extractor pusty, HTTP strony) trafia do playback_events.

Filmy: tytul z okruszkow (motyw dooplay losuje "Watch"/"Now Playing:"),
linki nitroflare/rapidgator nie sa juz brane za wideo, sam landing idzie
do WebView, a przytrzymanie w apce nie zabija ostatniego linku filmu,
jesli serwer widzi, ze zyje. Skrypty naprawcze: tytuly dooplay,
tytuly i miniatury hentaimama, repair_dooplay_movies dla streampornvip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:12:35 +02:00

144 lines
6.4 KiB
Python

"""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/<seria>-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 `<div class="sgeneros">`,
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 <h1>: na stronie są trzy <h1>, 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'<div class="sgeneros">(.*?)</div>', re.DOTALL)
_ANCHOR_TEXT_RE = re.compile(r">\s*([^<]{2,40}?)\s*</a>")
_DATE_RE = re.compile(r'<span class="date">\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ż `<meta property=... content=...>`,
# 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,
)
],
)