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.
This commit is contained in:
goon-foss 2026-08-13 08:47:04 +02:00
parent fcfac697cf
commit 2939e03d59
6 changed files with 291 additions and 0 deletions

View file

@ -51,6 +51,7 @@ 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.hentaigasm import HentaiGasmScraper
from app.connectors.direct_scrapers.hentaiplay import HentaiPlayScraper from app.connectors.direct_scrapers.hentaiplay import HentaiPlayScraper
from app.connectors.direct_scrapers.rule34video import Rule34VideoScraper
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
@ -218,6 +219,10 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
# (hentaiplanet.info). Metadane z RSS-a, bo /genre/ w HTML to 2084 linki chmury, # (hentaiplanet.info). Metadane z RSS-a, bo /genre/ w HTML to 2084 linki chmury,
# najwiecej w calym wertykale. Playback: mp4 wprost w HTML, bez CF i bez proxy. # najwiecej w calym wertykale. Playback: mp4 wprost w HTML, bez CF i bez proxy.
HentaiPlayScraper, HentaiPlayScraper,
# Rule34VideoScraper — dodany 2026-08-13, pierwsze zrodlo 3D (SFM/Blender).
# Bramka >=60s odrzuca ~47% katalogu (mediana 68s), bo apka i tak filtruje
# ponizej minuty. Tagi TYLKO z class=tag_item, `class=button` to chmura.
Rule34VideoScraper,
# 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,168 @@
"""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,
)
],
)

View file

@ -40,6 +40,7 @@ from app.extractors.tubes import (
hentaigasm, hentaigasm,
hentaiplay, hentaiplay,
hentaimama, hentaimama,
rule34video,
xanimeporn, xanimeporn,
kporner8, kporner8,
sexu, sexu,
@ -149,6 +150,11 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
# 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.
# rule34video — KVS z JAWNYMI flashvars (4 jakosci), ale `v-acctoken` jest
# ZWIAZANY Z IP tego, kto pobral strone (test krzyzowy: proxy→VPS = 403).
# Dlatego BEZ mobile_direct_ok: telefon resolwuje sam, upfront, przez
# isGetFileUrl()/getfileResolver, tak jak hdporn.gg i fullmovies.xxx.
"rule34video": rule34video.extract,
"xanimeporn": xanimeporn.extract, "xanimeporn": xanimeporn.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

View file

@ -0,0 +1,69 @@
"""rule34video.com — KVS `get_file` ZWIĄZANY Z IP. Dodany 2026-08-13.
Silnik to KVS, ale bez zaciemniania: flashvars trzymają cztery gotowe URL-e, bez
`function/0/` i bez potrzeby dekodowania `license_code`:
video_url: …/get_file/51/<hash>/4543000/4543893/4543893_360.mp4/?v-acctoken=…
video_alt_url: … _480p.mp4 (video_alt_url_text: '480p')
video_alt_url2: … _720p.mp4 (video_alt_url2_text: '720p')
video_alt_url3: … _1080p.mp4 (video_alt_url3_text: '1080p')
**`v-acctoken` jest związany z IP tego, kto pobrał STRONĘ.** Test krzyżowy 2026-08-13:
URL wygenerowany przez proxy i pobrany z VPS-a → **403**; ten sam URL pobrany z IP, które
go wygenerowało → 206, `ftypisom`, 70 MB. Po zdekodowaniu (`4533|163838|0|<md5>`) adresu
nie widać wprost, w odróżnieniu od fpoxxx, gdzie IP siedziało w treści tokenu — czyli
samo oglądanie tokenu NIE wystarcza, żeby to wykryć, trzeba zrobić test krzyżowy.
Dlatego **NIE ma tu `mobile_direct_ok`**: URL zresolwowany na VPS-ie jest dla telefonu
martwy. Zamiast tego oddajemy go jako zwykły link `get_file`, a resztę robi istniejąca
maszyneria po stronie telefonu: `SceneDetailScreen` wykrywa `isGetFileUrl()` i JESZCZE
PRZED wejściem do playera pobiera stronę z telefonu (token dla IP telefonu), a
`getfileResolver` wyciąga z niej flashvars. Ten sam mechanizm obsługuje hdporn.gg
i fullmovies.xxx, tam różnicą jest tylko `<source>` zamiast flashvars.
Backend zwraca więc URL-e po to, żeby apka znała listę jakości i etykiety, nie po to,
żeby je odtwarzać z VPS-a.
"""
from __future__ import annotations
import logging
import re
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import StreamSource
log = logging.getLogger(__name__)
_BASE = "https://rule34video.com"
_URL_RE = re.compile(r"(video_(?:alt_)?url\d*)\s*:\s*'(https?://[^']+\.mp4[^']*)'")
_LABEL_RE = re.compile(r"(video_(?:alt_)?url\d*)_text\s*:\s*'([^']{1,12})'")
#: Kolejność od najlepszej jakości. 4K nie występuje, serwis kończy na 1080p.
_QUALITY_RANK = {"1080p": 0, "720p": 1, "480p": 2, "360p": 3}
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html_text = fetch_tube_html(page_url, timeout=timeout)
labels = dict(_LABEL_RE.findall(html_text))
out: list[StreamSource] = []
seen: set[str] = set()
for var, link in _URL_RE.findall(html_text):
if link in seen:
continue
seen.add(link)
out.append(
StreamSource(
link=link,
type="mp4",
quality=labels.get(var),
referer=_BASE + "/",
# Bez `mobile_direct_ok`: token jest IP-bound, telefon resolwuje sam.
raw={},
)
)
if not out:
log.info("rule34video: brak video_url we flashvars na %s", page_url)
return None
out.sort(key=lambda s: _QUALITY_RANK.get((s.quality or "").lower(), 99))
return out

View file

@ -16,6 +16,14 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-08-13',
date: 'August 2026',
items: [
'New: animated and 3D content. Five sources have been added, four hentai and one 3D animation, each with its own catalogue rather than reposts of the others.',
'Playback for the 3D source is resolved on your phone, so you get the full quality ladder up to 1080p instead of a stripped-down stream.',
],
},
{ {
id: '2026-08-11', id: '2026-08-11',
date: 'August 2026', date: 'August 2026',

View file

@ -23,6 +23,14 @@ export function isGetFileUrl(url: string): boolean {
const _SOURCE_RE = /<source\s+src=['"]([^'"]+\/get_file\/[^'"]+\.mp4[^'"]*)['"][^>]*?(?:title|label)=['"]?([^'">]*)/gi; const _SOURCE_RE = /<source\s+src=['"]([^'"]+\/get_file\/[^'"]+\.mp4[^'"]*)['"][^>]*?(?:title|label)=['"]?([^'">]*)/gi;
const _SKIP_Q = /2160|1440|4k/i; const _SKIP_Q = /2160|1440|4k/i;
// rule34video (KVS) NIE ma <source> — cztery jakości siedzą we flashvars playera:
// video_url: '…/get_file/…_360.mp4…' video_url_text: '360p'
// video_alt_url2: '…_720p.mp4…' video_alt_url2_text: '720p'
// Token `v-acctoken` jest zbindowany do IP tego, kto pobrał stronę, więc dokładnie tak
// samo jak przy hdporn.gg musi to zrobić telefon, nie VPS.
const _FLASHVAR_URL_RE = /(video_(?:alt_)?url\d*)\s*:\s*'([^']+\/get_file\/[^']+\.mp4[^']*)'/g;
const _FLASHVAR_LABEL_RE = /(video_(?:alt_)?url\d*)_text\s*:\s*'([^']{1,12})'/g;
/** /**
* Pełny resolve dla hdporn.gg/fullmovies.xxx OD STRONY (nie od get_file URL). * Pełny resolve dla hdporn.gg/fullmovies.xxx OD STRONY (nie od get_file URL).
* *
@ -62,6 +70,33 @@ export async function resolveGetFilePage(
} }
if (!pick) pick = u; // pierwszy nie-4K jako fallback if (!pick) pick = u; // pierwszy nie-4K jako fallback
} }
// Brak <source> → spróbuj flashvars (rule34video). Etykiety leżą w osobnych polach
// `<var>_text`, więc najpierw je zbieramy, potem dopasowujemy po nazwie zmiennej.
if (!pick) {
const labels = new Map<string, string>();
let lm: RegExpExecArray | null;
_FLASHVAR_LABEL_RE.lastIndex = 0;
while ((lm = _FLASHVAR_LABEL_RE.exec(html)) !== null) labels.set(lm[1], lm[2].trim());
// Najlepsza dostępna jakość jako fallback, gdy backend nie podał preferencji.
const rank = (q: string) => ({ '1080p': 0, '720p': 1, '480p': 2, '360p': 3 }[q.toLowerCase()] ?? 99);
let best: { url: string; q: string } | null = null;
let fm: RegExpExecArray | null;
_FLASHVAR_URL_RE.lastIndex = 0;
while ((fm = _FLASHVAR_URL_RE.exec(html)) !== null) {
const q = labels.get(fm[1]) || '';
if (_SKIP_Q.test(q)) continue;
let u = fm[2];
if (u.startsWith('//')) u = 'https:' + u;
if (wantQ && q && q.replace(/\s+/g, '').toLowerCase() === wantQ) {
best = { url: u, q };
break; // dokładne dopasowanie jakości
}
if (!best || rank(q) < rank(best.q)) best = { url: u, q };
}
if (best) pick = best.url;
}
if (!pick) return null; if (!pick) return null;
const ref = referer || (() => { const ref = referer || (() => {
try { try {