"""naughtymachinima.com — browse scraper. Szóste źródło wertykału, drugie 3D. Dodany 2026-08-13. Machinima i animacje SFM/Blender, treść tworzona przez użytkowników. Kanon nie istnieje tak samo jak przy hentai i [[rule34video]], więc każda pozycja jest orphanem i to poprawne, bez obsady i bez canonical matcha. **Najzdrowsza jednostka treści z całego wertykału 3D.** Próbka 25 najnowszych: mediana 289 s, ZERO pozycji poniżej minuty, 18/25 powyżej progu potoku (180 s). Dla porównania rule34video ma medianę 68 s i traci ~70% katalogu na krótkich pętlach. Bramkę zostawiamy i tak (ten sam `min_ingest_duration_sec` co przy rule34video), żeby `seen` mówiło, ile realnie wchodzi, ale tutaj odsiewa tylko ~28%. **Szósty niezależny host w wertykale**: pliki leżą na `videos.naughtymachinima.com`, obok `gdvid.info` ([[hentaimama]]), `download.xanimeporn.com` ([[xanimeporn]]), `hgasm.com` ([[hentaigasm]]), `hentaiplanet.info` ([[hentaiplay]]) i `boomio-cdn.com` ([[rule34video]]). Żadna z tych stron nie re-hostuje pozostałych. Metadane są czyste, bez chmury z sidebara, która wyskoczyła przy siedmiu poprzednich kandydatach: na stronie filmu jest dokładnie 7 anchorów `/tags/` i wszystkie należą do filmu (przy „Dreamhawk: The Price Of Joining" są to dreamhawk, aerith gainsborough, don corneo). Mimo to trzymamy się `class="tag"`, żeby ewentualna przebudowa szablonu nie wciągnęła nam nawigacji. **Autora (`/user/`) zapisujemy jako studio**, tak samo jak przy rule34video: dla animacji to wytwórca, a nie performer. Paginacja `/videos?page=N` jest chronologiczna, strony 1, 2 i 3 mają zerowe pokrycie. Playback: jawne `` bez tokenu, patrz extractor `naughtymachinima`. """ from __future__ import annotations import html as html_mod import logging import re from datetime import date from app.config import get_settings from app.connectors.base import RawPlaybackSource, RawScene, RawStudio, RawTag from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper from app.normalize.text import slugify log = logging.getLogger(__name__) _BASE = "https://www.naughtymachinima.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 [[rule34video]]: czytamy ustawienie zamiast wpisywać liczbę, żeby liczniki `seen`/`skipped` nie kłamały.""" return getattr(get_settings(), "min_ingest_duration_sec", 0) or _APP_MIN_DURATION_SEC _SCENE_HREF_RE = re.compile( r'href="(?:https?://(?:www\.)?naughtymachinima\.com)?(/video/\d+/[^"?#]+)"' ) _TITLE_RE = re.compile(r"]*>\s*([^<]{2,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'property="og:image"[^>]*content="([^"]{10,300})"') _TAG_RE = re.compile(r']*href="[^"]*/tags/[^"]+"[^>]*>\s*([^<]{1,60}?)\s*') #: Autor: `Zector95`. _USER_RE = re.compile(r'href="[^"]*/user/([A-Za-z0-9._-]{2,40})"') 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()) return (hours * 3600 + minutes * 60 + seconds) or None class NaughtyMachinimaScraper(BaseBrowseScraper): sitetag = "naughtymachinima" def _listing_url(self, page: int) -> str: return f"{_BASE}/videos" if page <= 1 else f"{_BASE}/videos?page={page}" def _extract_scene_urls(self, listing_html: str) -> list[str]: return [_BASE + p for p in 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 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() 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)) studio = None um = _USER_RE.search(detail_html) if um: name = html_mod.unescape(um.group(1)).strip() slug = slugify(name) if slug: studio = RawStudio( external_id=f"{self.sitetag}:studio:{slug}", name=name, slug=slug ) video_id = scene_url.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, ) ], )