From 2939e03d598518b85e95a08d9bc66ee3b5be3bdc Mon Sep 17 00:00:00 2001 From: goon-foss Date: Thu, 13 Aug 2026 08:47:04 +0200 Subject: [PATCH] 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 (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. --- app/connectors/direct_scrapers/__init__.py | 5 + app/connectors/direct_scrapers/rule34video.py | 168 ++++++++++++++++++ app/extractors/__init__.py | 6 + app/extractors/tubes/rule34video.py | 69 +++++++ mobile/src/changelog.ts | 8 + mobile/src/lib/getfileResolver.ts | 35 ++++ 6 files changed, 291 insertions(+) create mode 100644 app/connectors/direct_scrapers/rule34video.py create mode 100644 app/extractors/tubes/rule34video.py diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index 10d06da..05b810b 100644 --- a/app/connectors/direct_scrapers/__init__.py +++ b/app/connectors/direct_scrapers/__init__.py @@ -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.hentaigasm import HentaiGasmScraper 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.fullporner import FullPornerScraper 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, # najwiecej w calym wertykale. Playback: mp4 wprost w HTML, bez CF i bez proxy. 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 # 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured". # Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania. diff --git a/app/connectors/direct_scrapers/rule34video.py b/app/connectors/direct_scrapers/rule34video.py new file mode 100644 index 0000000..0488cca --- /dev/null +++ b/app/connectors/direct_scrapers/rule34video.py @@ -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/`: + + cell (dbz) ← chmura „Top Tags", 25 szt. + handjob ← 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'

\s*([^<]{1,200}?)\s*

') +_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']*href="[^"]*/tags/\d+/"[^>]*>\s*([^<]{1,60}?)\s*') +#: Autor i kategorie — `video_meta_pill`, nazwa bywa opakowana w zagnieżdżone tagi. +_PILL_RE = re.compile( + r']*href="[^"]*/(models|categories)/([a-z0-9-]+)/"[^>]*>(.*?)', + 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, + ) + ], + ) diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py index e8eede5..3c228aa 100644 --- a/app/extractors/__init__.py +++ b/app/extractors/__init__.py @@ -40,6 +40,7 @@ from app.extractors.tubes import ( hentaigasm, hentaiplay, hentaimama, + rule34video, xanimeporn, kporner8, sexu, @@ -149,6 +150,11 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = { # xanimeporn — Fluid Player z PUSTYM , wideo wstrzykiwane w runtime. # Wyjście: zakładka Downloads, czyli videos.xanimeporn.com/download.php?id=. # 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, # 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 diff --git a/app/extractors/tubes/rule34video.py b/app/extractors/tubes/rule34video.py new file mode 100644 index 0000000..e8d92da --- /dev/null +++ b/app/extractors/tubes/rule34video.py @@ -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//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|`) 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 `` 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 diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index ec839f8..76d7d46 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,14 @@ export type 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', date: 'August 2026', diff --git a/mobile/src/lib/getfileResolver.ts b/mobile/src/lib/getfileResolver.ts index 4948b16..233d762 100644 --- a/mobile/src/lib/getfileResolver.ts +++ b/mobile/src/lib/getfileResolver.ts @@ -23,6 +23,14 @@ export function isGetFileUrl(url: string): boolean { const _SOURCE_RE = /]*?(?:title|label)=['"]?([^'">]*)/gi; const _SKIP_Q = /2160|1440|4k/i; +// rule34video (KVS) NIE ma — 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). * @@ -62,6 +70,33 @@ export async function resolveGetFilePage( } if (!pick) pick = u; // pierwszy nie-4K jako fallback } + + // Brak → spróbuj flashvars (rule34video). Etykiety leżą w osobnych polach + // `_text`, więc najpierw je zbieramy, potem dopasowujemy po nazwie zmiennej. + if (!pick) { + const labels = new Map(); + 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; const ref = referer || (() => { try {