- superporn: feed /videos/latest/N zamiast rotujacej strony glownej - pornbusy: karty ze strony glownej przed spoznion sitemapa - dooplay/streamporn: nowy motyw (card__th, h1 vid__t, data-servers JSON, st--duration, time datetime, chipy gatunkow), odstep i ponowienia w _fetch - porndoe: listing z sitemap_videos.xml; None gdy zadna strona sceny nie parsuje - hentaimama: /episodes/feed/ poza regexem odcinkow - watchdog: progi per zrodlo dla serwisow publikujacych rzadko
129 lines
5.5 KiB
Python
129 lines
5.5 KiB
Python
"""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".
|
|
# `/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 — 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,
|
|
)
|
|
],
|
|
)
|