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:
parent
fcfac697cf
commit
2939e03d59
6 changed files with 291 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
168
app/connectors/direct_scrapers/rule34video.py
Normal file
168
app/connectors/direct_scrapers/rule34video.py
Normal 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,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
@ -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 <source>, wideo wstrzykiwane w runtime.
|
||||
# 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.
|
||||
# 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
|
||||
|
|
|
|||
69
app/extractors/tubes/rule34video.py
Normal file
69
app/extractors/tubes/rule34video.py
Normal 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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ export function isGetFileUrl(url: string): boolean {
|
|||
const _SOURCE_RE = /<source\s+src=['"]([^'"]+\/get_file\/[^'"]+\.mp4[^'"]*)['"][^>]*?(?:title|label)=['"]?([^'">]*)/gi;
|
||||
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).
|
||||
*
|
||||
|
|
@ -62,6 +70,33 @@ export async function resolveGetFilePage(
|
|||
}
|
||||
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;
|
||||
const ref = referer || (() => {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue