goon/app/connectors/direct_scrapers/rule34video.py
goon-foss 2939e03d59 feat(3d): konektor rule34video - piate zrodlo wertykalu, pierwsze 3D
Animacje SFM/Blender. Kanon nie istnieje tak samo jak przy hentai, wiec
kazda pozycja jest orphanem i to poprawne.

Playback: KVS z jawnymi flashvars (4 jakosci, bez license_code), ale
`v-acctoken` jest ZWIAZANY Z IP tego, kto pobral strone. Test krzyzowy:
URL wygenerowany przez proxy i pobrany z VPS = 403, ten sam URL z IP
generujacego = 206 ftypisom 70 MB. Po zdekodowaniu tokenu adresu nie
widac wprost (inaczej niz fpoxxx), wiec samo ogladanie nie wystarcza.

Dlatego bez mobile_direct_ok: telefon resolwuje sam, UPFRONT, istniejaca
sciezka isGetFileUrl()/resolveGetFilePage z SceneDetailScreen. Rozszerzam
getfileResolver o flashvars, bo dotad umial tylko <source> (hdporn.gg,
fullmovies.xxx). Zweryfikowane end-to-end z residential IP: strona ->
flashvars 720p -> 302 na boomio-cdn -> 206 ftypisom 39,8 MB.

Bramka dlugosci zestrojona z `min_ingest_duration_sec` (prod 180 s)
zamiast wlasnej liczby: pierwsza wersja miala 60 s i liczniki klamaly
(seen 57, new 31, skipped 26). Po zestrojeniu seen 20, new 20, skipped 0.
Krotka tresc wycina ~70% katalogu (mediana 68 s).

Tagi TYLKO z class=tag_item. `class=button` wyglada na wlasciwe, a jest
chmura "Top Tags": przy filmie o Lux siedzialy tam "cell (dbz)" i
"queen of pain (dota)". Autor animacji zapisany jako studio.
2026-08-13 08:47:04 +02:00

168 lines
7.2 KiB
Python

"""rule34video.com — browse scraper. Piąte źródło wertykału, pierwsze 3D. Dodany 2026-08-13.
Inna charakterystyka niż cztery strony hentai ([[hentaimama]], [[xanimeporn]],
[[hentaigasm]], [[hentaiplay]]): to nie są odcinki serii, tylko pojedyncze animacje
SFM/Blender robione przez autorów. Kanon nie istnieje tak samo jak przy hentai, więc
każda pozycja jest orphanem i to poprawne, bez obsady i bez canonical matcha.
**Krótka treść wycina ~70% katalogu.** Rozkład 30 najnowszych: mediana 68 s, 14 pozycji
poniżej minuty, najkrótsza 7 s. Dla animacji SFM taka pętla to kompletna praca, a nie
wycinek (inaczej niż przy sexu, gdzie klipy były okrojoną wersją scen istniejących gdzie
indziej), ale nie mieści się w jednostce treści, na której stoi reszta systemu.
Bramkujemy tym samym progiem co potok (`min_ingest_duration_sec`, na prodzie 180 s),
zamiast własną liczbą. Pierwsza wersja miała 60 s (tyle filtruje apka) i przez to liczniki
kłamały: pilot na 3 stronach dał `seen 57, new 31, skipped 26`, bo `_skip_short_tube_scene`
i tak dorzynał wszystko poniżej 3 minut. Przy zestrojonym progu `seen` mówi, ile realnie
wchodzi, a to jest sygnał, którego używa watchdog i `source_stats`.
**Pułapka tagów, ODWROTNA do intuicji.** Na stronie są dwa zestawy anchorów `/tags/`:
<a class="button" href="/tags/35455/">cell (dbz)</a> ← chmura „Top Tags", 25 szt.
<a class="tag_item" href="/tags/122/">handjob</a> ← realne tagi filmu, 4 szt.
`class="button"` wygląda na „ten właściwy", a jest chmurą z sidebara: przy filmie o Lux
z League of Legends siedzą tam „cell (dbz)" i „queen of pain (dota)". Bierzemy WYŁĄCZNIE
`tag_item`, inaczej każdy film dostałby ten sam zestaw 25 śmieci.
Autor i kategorie mają własną klasę `video_meta_pill` i występują po 1-2 razy, czyli tam
chmury nie ma. **Autora zapisujemy jako studio**, bo dla 3D to on jest wytwórcą
(Monarchnsfw, jackerman, derpixon), a nie żaden performer.
Paginacja `/latest-updates/N/` jest chronologiczna i sprawdzona: strony 1, 2 i 3 mają
zerowe pokrycie (w odróżnieniu od freshporno czy streamporn.vip, gdzie listing rotował).
Playback: `get_file` z tokenem ZWIĄZANYM Z IP, patrz extractor `rule34video`.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import date
from app.connectors.base import RawPlaybackSource, RawScene, RawStudio, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.config import get_settings
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://rule34video.com"
#: Fallback, gdy `min_ingest_duration_sec` jest wyłączone (0): tyle filtruje apka.
_APP_MIN_DURATION_SEC = 60
def _min_duration() -> int:
"""Próg zestrojony z potokiem, patrz docstring. Czytamy ustawienie zamiast wpisywać
liczbę, żeby zmiana `min_ingest_duration_sec` nie rozjechała się z tym scraperem."""
return getattr(get_settings(), "min_ingest_duration_sec", 0) or _APP_MIN_DURATION_SEC
_SCENE_HREF_RE = re.compile(r'href="(https?://rule34video\.com/video/\d+/[^"]+)"')
_TITLE_RE = re.compile(r'<h1 class="title_video">\s*([^<]{1,200}?)\s*</h1>')
_DURATION_RE = re.compile(r'"duration"\s*:\s*"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?"')
_UPLOAD_DATE_RE = re.compile(r'"uploadDate"\s*:\s*"(\d{4})-(\d{2})-(\d{2})')
_THUMB_RE = re.compile(r'"thumbnailUrl"\s*:\s*\[?\s*"([^"]{10,300})"')
#: TYLKO `tag_item`, patrz odwrócona pułapka w docstringu.
_TAG_RE = re.compile(r'<a class="tag_item"[^>]*href="[^"]*/tags/\d+/"[^>]*>\s*([^<]{1,60}?)\s*</a>')
#: Autor i kategorie — `video_meta_pill`, nazwa bywa opakowana w zagnieżdżone tagi.
_PILL_RE = re.compile(
r'<a class="[^"]*video_meta_pill[^"]*"[^>]*href="[^"]*/(models|categories)/([a-z0-9-]+)/"[^>]*>(.*?)</a>',
re.DOTALL,
)
_STRIP_TAGS_RE = re.compile(r"<[^>]+>")
def _text(fragment: str) -> str:
return html_mod.unescape(_STRIP_TAGS_RE.sub(" ", fragment)).strip()
def _duration(detail_html: str) -> int | None:
m = _DURATION_RE.search(detail_html)
if not m:
return None
hours, minutes, seconds = (int(x or 0) for x in m.groups())
total = hours * 3600 + minutes * 60 + seconds
return total or None
class Rule34VideoScraper(BaseBrowseScraper):
sitetag = "rule34video"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/latest-updates/" if page <= 1 else f"{_BASE}/latest-updates/{page}/"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return list(dict.fromkeys(_SCENE_HREF_RE.findall(listing_html)))
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
tm = _TITLE_RE.search(detail_html)
if not tm:
return None
title = html_mod.unescape(tm.group(1)).strip()
if not title:
return None
duration_sec = _duration(detail_html)
if duration_sec is None or duration_sec < _min_duration():
return None # krótka pętla, potok i tak by ją odrzucił jako trailer
release_date = None
dm = _UPLOAD_DATE_RE.search(detail_html)
if dm:
try:
release_date = date(int(dm.group(1)), int(dm.group(2)), int(dm.group(3)))
except ValueError:
pass
thumbnail_url = None
im = _THUMB_RE.search(detail_html)
if im:
thumbnail_url = im.group(1).strip().replace("\\/", "/")
tags: list[RawTag] = []
seen_tag: set[str] = set()
for raw_name in _TAG_RE.findall(detail_html):
name = html_mod.unescape(raw_name).strip()
slug = slugify(name)
if slug and slug not in seen_tag:
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
# Autor animacji → studio (dla 3D to wytwórca, nie performer). Kategorie → tagi.
studio = None
for kind, _slug_in_url, inner in _PILL_RE.findall(detail_html):
name = _text(inner)
slug = slugify(name)
if not name or not slug:
continue
if kind == "models":
if studio is None:
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slug}", name=name, slug=slug
)
elif slug not in seen_tag:
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
video_id = scene_url.rstrip("/").split("/video/")[-1].split("/")[0]
return RawScene(
external_id=f"{self.sitetag}:{video_id}",
title=title,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=[], # animacja, obsada nie istnieje
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)