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.
168 lines
7.2 KiB
Python
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,
|
|
)
|
|
],
|
|
)
|