feat(hentai): konektor hentaimama.io - pierwsze zrodlo wertykalu hentai
Jednostka tresci to ODCINEK (/episodes/<seria>-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 <noreply@anthropic.com>
This commit is contained in:
parent
a4486980a4
commit
85ee42d5a3
4 changed files with 261 additions and 0 deletions
|
|
@ -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.pornbusy import PornBusyScraper
|
||||||
from app.connectors.direct_scrapers.pornmike import PornMikeScraper
|
from app.connectors.direct_scrapers.pornmike import PornMikeScraper
|
||||||
from app.connectors.direct_scrapers.kporner8 import KPorner8Scraper
|
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.fullporner import FullPornerScraper
|
||||||
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
|
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
|
||||||
from app.connectors.direct_scrapers.youperv import YoupervScraper
|
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.
|
# 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.
|
# Pilot: seen 62, new 25, updated 37, 60% dopina się do scen, które już mamy.
|
||||||
KPorner8Scraper,
|
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
|
# 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".
|
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
|
||||||
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
|
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
|
||||||
|
|
|
||||||
128
app/connectors/direct_scrapers/hentaimama.py
Normal file
128
app/connectors/direct_scrapers/hentaimama.py
Normal file
|
|
@ -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/<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 — 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 `<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 — bez tego
|
||||||
|
# filtra wchodziły jako „odcinek" o tytule „Recent Episodes".
|
||||||
|
_NOT_EPISODE_RE = re.compile(r"/episodes/(?:page/\d+/)?$")
|
||||||
|
# `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 — 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})')
|
||||||
|
_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 — 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,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
@ -37,6 +37,7 @@ from app.extractors.tubes import (
|
||||||
perverzija,
|
perverzija,
|
||||||
hqporner,
|
hqporner,
|
||||||
fullporner,
|
fullporner,
|
||||||
|
hentaimama,
|
||||||
kporner8,
|
kporner8,
|
||||||
sexu,
|
sexu,
|
||||||
javflix,
|
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
|
# 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".
|
# dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever".
|
||||||
"8kpornercom": kporner8.extract,
|
"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.
|
# 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
|
# 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.
|
# UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi.
|
||||||
|
|
|
||||||
119
app/extractors/tubes/hentaimama.py
Normal file
119
app/extractors/tubes/hentaimama.py
Normal file
|
|
@ -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=<id>`)
|
||||||
|
→ 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=<epoch>`. 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'<iframe[^>]+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
|
||||||
Loading…
Add table
Reference in a new issue