"""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, ) ], )