From 85ee42d5a3012b03a6b27b05ed6575762775b6a3 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Wed, 12 Aug 2026 13:35:10 +0200 Subject: [PATCH] feat(hentai): konektor hentaimama.io - pierwsze zrodlo wertykalu hentai Jednostka tresci to ODCINEK (/episodes/-episode-N/), mapowany na scene tak samo jak w wertykale JAV, bez zmian w schemacie. Seria ladue jako tag, wiec odcinki daja sie grupowac. KRYTERIUM JEST TU INNE I TO CELOWE. Dla animacji nie istnieje kanon - TPDB i StashDB nie maja rysowanych postaci - wiec kazda scena stad jest technicznie orphanem i to jest POPRAWNE. Nie stosujemy ani bramki na obsade (nie ma obsady), ani miary canonical match, ktora sluzyla nam do oceny tubow z zywymi aktorami. Jakosc mierzymy kompletem seria + odcinek + gatunki. Motyw jest z rodziny Dooplay, ale DooplayConnector NIE pasuje: brak data-fl-source i data-post, na ktorych stoi tamten parser. Player idzie w trzech krokach: id posta ze strony odcinka, POST /wp-admin/admin-ajax.php z action=get_player_contents, potem strona wlasnego playera, ktora oddaje bezposredni mp4. Sam POST jest za Cloudflare (GET-y przechodza), wiec wymaga Bright Data, dokladnie jak POST /api/video-info w sexu. Playback najlepszy z mozliwych: preferujemy gdvid.info, bo oddaje plik BEZ tokenu, wiec URL nie wygasa. Zweryfikowane z VPS: 206 na Range, ftypisom (faststart), dziala BEZ Referera, stad mobile_direct_ok. Pulapka obeszla: /genre/ w calym HTML to 118 linkow (chmura w sidebarze, ten sam wzorzec co przy sexu i yourdailypornmovies). Gatunki bierzemy tylko z div.sgeneros, co daje 5-10 realnych pozycji na odcinek. Pilot: seen 90, new 90, errors 0. W bazie 90 odcinkow, 100 procent z data i tagami, zakres 2025-12-29 do 2026-07-31. Co-Authored-By: Claude Opus 5 --- app/connectors/direct_scrapers/__init__.py | 9 ++ app/connectors/direct_scrapers/hentaimama.py | 128 +++++++++++++++++++ app/extractors/__init__.py | 5 + app/extractors/tubes/hentaimama.py | 119 +++++++++++++++++ 4 files changed, 261 insertions(+) create mode 100644 app/connectors/direct_scrapers/hentaimama.py create mode 100644 app/extractors/tubes/hentaimama.py diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index 957c686..c947afe 100644 --- a/app/connectors/direct_scrapers/__init__.py +++ b/app/connectors/direct_scrapers/__init__.py @@ -48,6 +48,7 @@ from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper from app.connectors.direct_scrapers.pornbusy import PornBusyScraper 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.fullporner import FullPornerScraper from app.connectors.direct_scrapers.watchporn import WatchPornScraper from app.connectors.direct_scrapers.youperv import YoupervScraper @@ -190,6 +191,14 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [ # na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb. # Pilot: seen 62, new 25, updated 37, 60% dopina się do scen, które już mamy. KPorner8Scraper, + # HentaiMamaScraper — dodany 2026-08-04, PIERWSZE źródło wertykału hentai. + # UWAGA na kryterium: dla animacji NIE ISTNIEJE kanon (TPDB/StashDB nie mają + # rysowanych postaci), więc każda scena stąd jest orphanem i to jest POPRAWNE. + # Nie stosujemy tu ani bramki na obsadę (nie ma obsady), ani miary canonical match. + # Jakość mierzymy kompletem seria + odcinek + gatunki. Jednostka = ODCINEK. + # Motyw z rodziny Dooplay, ale DooplayConnector NIE pasuje (brak data-fl-source). + # Playback: extractor `hentaimama`, POST admin-ajax za Cloudflare → Bright Data. + HentaiMamaScraper, # 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/hentaimama.py b/app/connectors/direct_scrapers/hentaimama.py new file mode 100644 index 0000000..202e9a8 --- /dev/null +++ b/app/connectors/direct_scrapers/hentaimama.py @@ -0,0 +1,128 @@ +"""hentaimama.io — 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 — 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 — 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 — 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 — bez tego +# filtra wchodziły jako „odcinek" o tytule „Recent Episodes". +_NOT_EPISODE_RE = re.compile(r"/episodes/(?:page/\d+/)?$") +# `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 — 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})') +_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 — stąd luźny wzorzec i strip() przy użyciu. +_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 "" + 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 — 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] + + return RawScene( + external_id=f"{self.sitetag}:{slug}", + title=title, + release_date=_parse_date(dm.group(1) if dm else None), + url=scene_url, + tags=tags, + playback_sources=[ + RawPlaybackSource( + origin=f"tube:{self.sitetag}", + page_url=scene_url, + thumbnail_url=om.group(1).strip() if om else None, + ) + ], + ) diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py index b17e05f..82bda4c 100644 --- a/app/extractors/__init__.py +++ b/app/extractors/__init__.py @@ -37,6 +37,7 @@ from app.extractors.tubes import ( perverzija, hqporner, fullporner, + hentaimama, kporner8, sexu, javflix, @@ -130,6 +131,10 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = { # okcdn/vkuser jak 4k69: srcIp nieegzekwowane → mobile direct. Cap 720p, bo CDN # dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever". "8kpornercom": kporner8.extract, + # hentaimama — player w 3 krokach: id posta ze strony → POST admin-ajax (ZA CF, + # wymaga Bright Data) → strona playera → bezpośredni mp4. Preferujemy gdvid, + # bo oddaje plik bez tokenu, więc URL nie wygasa. Portable, mobile_direct_ok. + "hentaimama": hentaimama.extract, # fullporner, iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości. # Zero tokenu i zero wygasania, URL składamy sami. Blokada jest na nagłówku (bez # UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi. diff --git a/app/extractors/tubes/hentaimama.py b/app/extractors/tubes/hentaimama.py new file mode 100644 index 0000000..9c1d35b --- /dev/null +++ b/app/extractors/tubes/hentaimama.py @@ -0,0 +1,119 @@ +"""hentaimama.io — bezpośredni mp4 przez trzystopniowe rozwiązanie playera. Dodany 2026-08-04. + +Strona odcinka NIE zawiera playera w HTML. Ścieżka wygląda tak: + + 1. GET strony odcinka → id posta z `action:'get_player_contents', a:'15377'` + 2. POST `/wp-admin/admin-ajax.php` (`action=get_player_contents`, `a=`) + → JSON z dwoma iframe'ami do WŁASNYCH playerów serwisu + 3. GET strony playera (`new2.php` / `newjav.php`) → bezpośredni link mp4 + +Parametr `p` w URL-u playera to base64 ścieżki pliku (`D/deco-x-deco-animation-1.mp4`), +ale nie trzeba go składać ręcznie — bierzemy iframe taki, jaki przyszedł. + +**Krok 2 wymaga Bright Data.** GET-y przechodzą z gołego IP, ale sam POST na +`admin-ajax.php` dostaje challenge Cloudflare (403, „Just a moment..."), dokładnie jak +`POST /api/video-info` przy sexu. Bez klucza proxy extractor zwraca None. + +**Preferujemy `gdvid.info` nad `javprovider.com`**: pierwszy oddaje plik BEZ tokenu, +więc URL nigdy nie wygasa; drugi ma `?st=...&e=`. Oba zweryfikowane z VPS-a: +206 na Range, `ftypisom` w pierwszym KB (faststart), działają BEZ Referera — stąd +`mobile_direct_ok`, telefon gra prosto z CDN. +""" +from __future__ import annotations + +import json +import logging +import re + +from app.config import get_settings +from app.extractors._fetch import fetch_tube_html +from app.extractors._models import StreamSource + +log = logging.getLogger(__name__) + +_BASE = "https://hentaimama.io" +_POST_ID_RE = re.compile(r"action:'get_player_contents',\s*a:'(\d+)'") +_IFRAME_SRC_RE = re.compile(r']+src="([^"]+)"', re.IGNORECASE) +_MP4_RE = re.compile(r'https?://[^\s"\'<>]+\.mp4[^\s"\'<>]*') +#: Host bez tokenu idzie pierwszy — jego URL nie wygasa. +_HOST_RANK = ("gdvid", "javprovider") + + +def _rank(url: str) -> int: + for i, host in enumerate(_HOST_RANK): + if host in url: + return i + return len(_HOST_RANK) + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + proxy = get_settings().brightdata_proxy_url + if not proxy: + log.info("hentaimama: brak BRIGHTDATA_PROXY_URL — CF zablokuje POST admin-ajax") + return None + + html_text = fetch_tube_html(page_url, timeout=timeout) + pm = _POST_ID_RE.search(html_text) + if not pm: + log.info("hentaimama: brak id posta na %s", page_url) + return None + + from curl_cffi import requests as cr + + try: + session = cr.Session( + impersonate="chrome120", + proxies={"http": proxy, "https": proxy}, + verify=False, + ) + # Strona odcinka najpierw — bez jej ciasteczek CF odrzuca POST-a. + session.get(page_url, timeout=timeout) + res = session.post( + f"{_BASE}/wp-admin/admin-ajax.php", + data={"action": "get_player_contents", "a": pm.group(1)}, + headers={"Referer": page_url, "X-Requested-With": "XMLHttpRequest"}, + timeout=timeout, + ) + fields = json.loads(res.text) + except Exception as e: + log.info("hentaimama: get_player_contents failed %s: %s", page_url, e) + return None + + players: list[str] = [] + for field in fields if isinstance(fields, list) else []: + for src in _IFRAME_SRC_RE.findall(str(field)): + if src.startswith("//"): + src = "https:" + src + if src.startswith("http") and src not in players: + players.append(src) + + out: list[StreamSource] = [] + seen: set[str] = set() + for player_url in players: + try: + body = session.get( + player_url, headers={"Referer": page_url}, timeout=timeout + ).text + except Exception as e: + log.info("hentaimama: player fetch failed %s: %s", player_url, e) + continue + for link in _MP4_RE.findall(body): + if link in seen: + continue + seen.add(link) + out.append( + StreamSource( + link=link, + type="mp4", + quality=None, # serwis nie podaje rozdzielczości + referer=_BASE + "/", + # Zweryfikowane: 206 bez Referera, faststart → telefon gra direct. + raw={"mobile_direct_ok": True}, + ) + ) + + if not out: + log.info("hentaimama: nie znalazłem mp4 dla %s", page_url) + return None + out.sort(key=lambda s: _rank(s.link)) + return out