feat(hentai): konektor hentaigasm.com - trzecie zrodlo wertykalu

TRZECI niezalezny host w wertykale. Te same odcinki co hentaimama i xanimeporn, ale
znowu inne pliki: tutaj hgasm<N>.com/<Tytul Ze Spacjami>.mp4, tam gdvid.info/N/... i
download.xanimeporn.com/videos/1080p/[1080p]<id>.mp4. Zadna z trzech stron nie
re-hostuje pozostalych.

Metadane w CALOSCI z RSS-a i to nie skrot tylko koniecznosc:
- strona odcinka NIE MA daty publikacji w zadnym standardowym miejscu (ani JSON-LD,
  ani article:published_time, ani <time>), a feed ma pubDate
- /genre/ w HTML to 38 linkow, czyli kolejna chmura z sidebara; feed daje 5 realnych
  kategorii, gdzie pierwsza to nazwa serii
- /page/2/ nie zwraca odcinkow w ogole, a /feed/?paged=N stronicuje chronologicznie

Playback najprostszy z calej trojki: konfiguracja JWPlayera z gotowym file: wprost w
HTML, bez AJAX-a i bez Cloudflare. Sciezka zawiera SPACJE, wiec kodujemy sama sciezke,
zostawiajac schemat i host. Host ROTUJE miedzy odcinkami (hgasm1/hgasm3), wiec czytamy
go ze strony zamiast wpisywac na sztywno.

Zweryfikowane z VPS: 206 na Range, ftypisom, 198 MB, dziala BEZ Referera i bez tokenu,
wiec URL nie wygasa. mobile_direct_ok.

Pilot: seen 107, new 107, errors 0. Metadane: 100 procent z data, miniaturka i tagami.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-08-12 14:57:28 +02:00
parent 7a8e10c175
commit a5295be10e
4 changed files with 209 additions and 0 deletions

View file

@ -49,6 +49,7 @@ 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.hentaimama import HentaiMamaScraper
from app.connectors.direct_scrapers.hentaigasm import HentaiGasmScraper
from app.connectors.direct_scrapers.xanimeporn import XAnimePornScraper from app.connectors.direct_scrapers.xanimeporn import XAnimePornScraper
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
@ -207,6 +208,11 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
# więc listing z dwóch archiwów: /uncensored/ i /censored/, które stronicują poprawnie. # więc listing z dwóch archiwów: /uncensored/ i /censored/, które stronicują poprawnie.
# Playback bez Cloudflare i bez proxy, w odróżnieniu od hentaimamy. # Playback bez Cloudflare i bez proxy, w odróżnieniu od hentaimamy.
XAnimePornScraper, XAnimePornScraper,
# HentaiGasmScraper — dodany 2026-08-04, trzecie zrodlo wertykalu i TRZECI
# niezalezny host (hgasm1.com). Metadane w CALOSCI z RSS-a, bo strona odcinka nie ma
# daty w zadnym standardowym miejscu, a /genre/ to 38 linkow chmury z sidebara.
# Playback najprostszy z calej trojki: JWPlayer z gotowym file: wprost w HTML.
HentaiGasmScraper,
# 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.

View file

@ -0,0 +1,131 @@
"""hentaigasm.com — browse scraper. Trzecie źródło wertykału hentai. Dodany 2026-08-04.
**Trzeci niezależny host w wertykale.** Te same odcinki co [[hentaimama]] i
[[xanimeporn]], ale znowu inne pliki: tutaj `hgasm1.com/<Tytuł Ze Spacjami>.mp4`,
tam `gdvid.info/N/...` i `download.xanimeporn.com/videos/1080p/[1080p]<id>.mp4`.
Żadna z tych trzech stron nie re-hostuje pozostałych.
Kryterium jak w całym wertykale: dla animacji nie istnieje kanon, więc każda pozycja
jest orphanem i to poprawne. Nie ma obsady ani canonical matcha.
**Wszystkie metadane bierzemy z RSS-a, nie ze strony odcinka**, i to nie jest skrót
tylko konieczność:
- strona odcinka NIE MA daty publikacji w żadnym standardowym miejscu (ani JSON-LD,
ani `article:published_time`, ani `<time>`), a feed ma `<pubDate>`
- `/genre/` w HTML strony to 38 linków, czyli kolejna chmura z sidebara, a feed daje
5 realnych kategorii: pierwsza to nazwa serii, reszta to gatunki
- `/page/2/` nie zwraca odcinków w ogóle, a `/feed/?paged=N` stronicuje poprawnie
i chronologicznie (strona 1 = dziś, strona 2 = tydzień wstecz)
Strona odcinka jest potrzebna wyłącznie do miniaturki, którą wyciąga z konfiguracji
JWPlayera razem z linkiem do wideo (patrz extractor `hentaigasm`).
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from email.utils import parsedate_to_datetime
from app.connectors.base import RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://hentaigasm.com"
_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.DOTALL)
_LINK_RE = re.compile(r"<link>\s*(https?://hentaigasm\.com/[^<\s]+)\s*</link>")
_TITLE_RE = re.compile(r"<title>(.*?)</title>", re.DOTALL)
_PUBDATE_RE = re.compile(r"<pubDate>(.*?)</pubDate>", re.DOTALL)
_CATEGORY_RE = re.compile(r"<category><!\[CDATA\[(.*?)\]\]></category>", re.DOTALL)
#: Miniaturka z konfiguracji JWPlayera — jedyna rzecz, po którą wchodzimy na stronę.
_JW_IMAGE_RE = re.compile(r"image:\s*[\"']([^\"']+)[\"']")
class HentaiGasmScraper(BaseBrowseScraper):
sitetag = "hentaigasm"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/feed/" if page <= 1 else f"{_BASE}/feed/?paged={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return list(dict.fromkeys(_LINK_RE.findall(listing_html)))
def crawl_page(self, page: int) -> list[RawScene] | None:
try:
res = browser_get(self._listing_url(page), timeout=self._timeout)
res.raise_for_status()
except Exception as e:
log.warning("%s: feed strona %d fail: %s", self.sitetag, page, e)
return None
items = _ITEM_RE.findall(res.text)
if not items:
return []
out: list[RawScene] = []
for item in items:
lm = _LINK_RE.search(item)
tm = _TITLE_RE.search(item)
if not lm or not tm:
continue
url = lm.group(1).strip()
title = html_mod.unescape(tm.group(1)).strip()
if not title:
continue
release_date = None
dm = _PUBDATE_RE.search(item)
if dm:
try:
release_date = parsedate_to_datetime(dm.group(1).strip()).date()
except (TypeError, ValueError):
pass
tags: list[RawTag] = []
seen: set[str] = set()
for raw_cat in _CATEGORY_RE.findall(item):
name = html_mod.unescape(raw_cat).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)
)
thumb = None
try:
det = browser_get(url, timeout=self._timeout)
det.raise_for_status()
im = _JW_IMAGE_RE.search(det.text)
if im:
thumb = im.group(1).strip()
except Exception as e:
# Brak miniaturki nie dyskwalifikuje odcinka — reszta metadanych jest z feedu.
log.info("%s: miniaturka fail %s: %s", self.sitetag, url, e)
slug = url.rstrip("/").rsplit("/", 1)[-1]
out.append(
RawScene(
external_id=f"{self.sitetag}:{slug}",
title=title,
release_date=release_date,
url=url,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=url,
thumbnail_url=thumb,
)
],
)
)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: # pragma: no cover
raise NotImplementedError("hentaigasm buduje sceny z RSS-a w crawl_page")

View file

@ -37,6 +37,7 @@ from app.extractors.tubes import (
perverzija, perverzija,
hqporner, hqporner,
fullporner, fullporner,
hentaigasm,
hentaimama, hentaimama,
xanimeporn, xanimeporn,
kporner8, kporner8,
@ -136,6 +137,10 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
# wymaga Bright Data) → strona playera → bezpośredni mp4. Preferujemy gdvid, # wymaga Bright Data) → strona playera → bezpośredni mp4. Preferujemy gdvid,
# bo oddaje plik bez tokenu, więc URL nie wygasa. Portable, mobile_direct_ok. # bo oddaje plik bez tokenu, więc URL nie wygasa. Portable, mobile_direct_ok.
"hentaimama": hentaimama.extract, "hentaimama": hentaimama.extract,
# hentaigasm — najprostszy w wertykale: konfiguracja JWPlayera WPROST w HTML,
# bez AJAX-a i bez CF. Sciezka ma SPACJE, trzeba zakodowac. Bez tokenu, bez
# Referera, 206 z VPS → mobile_direct_ok.
"hentaigasm": hentaigasm.extract,
# xanimeporn — Fluid Player z PUSTYM <source>, wideo wstrzykiwane w runtime. # xanimeporn — Fluid Player z PUSTYM <source>, wideo wstrzykiwane w runtime.
# Wyjście: zakładka Downloads, czyli videos.xanimeporn.com/download.php?id=<postId>. # Wyjście: zakładka Downloads, czyli videos.xanimeporn.com/download.php?id=<postId>.
# Oddajemy download.php, nie docelowy plik, żeby token generował się przy odtwarzaniu. # Oddajemy download.php, nie docelowy plik, żeby token generował się przy odtwarzaniu.

View file

@ -0,0 +1,67 @@
"""hentaigasm.com — bezpośredni mp4 z konfiguracji JWPlayera. Dodany 2026-08-04.
Najprostszy z całego wertykału hentai. Konfiguracja playera siedzi WPROST w HTML strony
odcinka, bez AJAX-a i bez Cloudflare:
jwplayer("player_01").setup({
file: "https://hgasm1.com/Nuki Nuki Zupposism 2 Subbed.mp4",
image: "https://hgasm1.com/thumbnail/Nuki Nuki Zupposism 2 Subbed.jpg",
...
})
Dla porównania: [[hentaimama]] trzyma to za POST-em na `admin-ajax` schowanym za
Cloudflare, a [[xanimeporn]] ma Fluid Player z pustym `<source>` i realny link dopiero
w zakładce „Downloads".
**Ścieżka zawiera SPACJE** i trzeba je zakodować — bez tego request się wywala. Kodujemy
tylko część po hoście, żeby nie ruszyć schematu ani kropek w domenie.
Zweryfikowane z VPS-a: 206 na Range, `ftypisom` w pierwszym KB (faststart), 198 MB,
działa BEZ Referera i bez tokenu, więc URL nie wygasa. Stąd `mobile_direct_ok`.
"""
from __future__ import annotations
import logging
import re
from urllib.parse import quote, urlsplit, urlunsplit
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import StreamSource
log = logging.getLogger(__name__)
_BASE = "https://hentaigasm.com"
_JW_FILE_RE = re.compile(r"file:\s*[\"'](https?://[^\"']+\.mp4)[\"']")
def _encode(url: str) -> str:
"""Zakoduj spacje w ścieżce, zostawiając schemat i host nietknięte."""
parts = urlsplit(url)
return urlunsplit(
(parts.scheme, parts.netloc, quote(parts.path), parts.query, parts.fragment)
)
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html_text = fetch_tube_html(page_url, timeout=timeout)
seen: set[str] = set()
out: list[StreamSource] = []
for raw in _JW_FILE_RE.findall(html_text):
link = _encode(raw.strip())
if link in seen:
continue
seen.add(link)
out.append(
StreamSource(
link=link,
type="mp4",
quality=None, # serwis publikuje jedną rendycję, bez etykiety
referer=_BASE + "/",
# Zweryfikowane: 206 bez Referera, bez tokenu, faststart.
raw={"mobile_direct_ok": True},
)
)
if not out:
log.info("hentaigasm: brak file: w konfiguracji JWPlayera na %s", page_url)
return None
return out