From 1c1e46130a1227bea7ab9547bd1725dd9103172b Mon Sep 17 00:00:00 2001 From: goon-foss Date: Thu, 13 Aug 2026 09:06:24 +0200 Subject: [PATCH] feat(3d): konektor naughtymachinima - szoste zrodlo wertykalu Machinima i animacje SFM/Blender. Kanon nie istnieje tak samo jak przy hentai i rule34video, wiec kazda pozycja jest orphanem i to poprawne. Najzdrowsza jednostka tresci calego wertykalu 3D: mediana 289 s, ZERO pozycji ponizej minuty, 18/25 powyzej progu potoku. Dla porownania rule34video ma mediane 68 s i traci ~70% katalogu. Playback najprostszy z serii: jawne w trzech jakosciach, bez tokenu, bez AJAX-a, bez CF. Zweryfikowane z VPS: 206, ftypisom, 140 MB, dziala bez Referera, wiec mobile_direct_ok i zero pracy po stronie apki (inaczej niz rule34video, gdzie token byl IP-bound). Szosty niezalezny host wertykalu (videos.naughtymachinima.com), zadna z szesciu stron nie re-hostuje pozostalych. Odsiewamy pusty (placeholder playera) i preroll /images/preroll/NMintro.mp4, ktory pasuje do naiwnego \.mp4. Bramka dlugosci zestrojona z min_ingest_duration_sec, jak przy rule34video. Pilot 3 strony: seen 48, new 48, skipped 0, errors 0. --- app/connectors/direct_scrapers/__init__.py | 4 + .../direct_scrapers/naughtymachinima.py | 148 ++++++++++++++++++ app/extractors/__init__.py | 4 + app/extractors/tubes/naughtymachinima.py | 67 ++++++++ 4 files changed, 223 insertions(+) create mode 100644 app/connectors/direct_scrapers/naughtymachinima.py create mode 100644 app/extractors/tubes/naughtymachinima.py diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index 05b810b..6a749b3 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.naughtymachinima import NaughtyMachinimaScraper from app.connectors.direct_scrapers.rule34video import Rule34VideoScraper from app.connectors.direct_scrapers.xanimeporn import XAnimePornScraper from app.connectors.direct_scrapers.fullporner import FullPornerScraper @@ -219,6 +220,9 @@ 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, + # NaughtyMachinimaScraper — dodany 2026-08-13, drugie zrodlo 3D i najzdrowsza + # jednostka tresci wertykalu (mediana 289s, zero ponizej minuty). + NaughtyMachinimaScraper, # 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. diff --git a/app/connectors/direct_scrapers/naughtymachinima.py b/app/connectors/direct_scrapers/naughtymachinima.py new file mode 100644 index 0000000..8b71d08 --- /dev/null +++ b/app/connectors/direct_scrapers/naughtymachinima.py @@ -0,0 +1,148 @@ +"""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, + ) + ], + ) diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py index 3c228aa..47a8e5f 100644 --- a/app/extractors/__init__.py +++ b/app/extractors/__init__.py @@ -40,6 +40,7 @@ from app.extractors.tubes import ( hentaigasm, hentaiplay, hentaimama, + naughtymachinima, rule34video, xanimeporn, kporner8, @@ -150,6 +151,9 @@ _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. + # naughtymachinima — jawne bez tokenu, najprostszy playback wertykalu. + # Odsiewamy pusty src="" (placeholder playera) i /images/preroll/NMintro.mp4. + "naughtymachinima": naughtymachinima.extract, # 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 diff --git a/app/extractors/tubes/naughtymachinima.py b/app/extractors/tubes/naughtymachinima.py new file mode 100644 index 0000000..7f52df1 --- /dev/null +++ b/app/extractors/tubes/naughtymachinima.py @@ -0,0 +1,67 @@ +"""naughtymachinima.com — jawne `` bez tokenu. Dodany 2026-08-13. + +Najprostszy playback z całego wertykału animacji. Trzy rendycje wprost w HTML, bez +AJAX-a, bez tokenu, bez Cloudflare: + + + +Kontrast z [[rule34video]] (drugie źródło 3D): tam też był KVS z gotowymi URL-ami, ale +`v-acctoken` był ZWIĄZANY Z IP i telefon musiał resolwować sam. Tutaj URL nie ma żadnego +tokenu, więc nie ma czego wiązać. + +Zweryfikowane z VPS-a: 206 na Range, `ftypisom` w pierwszym KB (faststart), 140 MB, +działa BEZ Referera. Stąd `mobile_direct_ok`. + +**Dwie pułapki, obie odsiewane jawnie:** + +1. **Pusty ``** na końcu listy (placeholder playera). Bez sprawdzenia + pustego src trafiłby na listę jako link do niczego. +2. **`/images/preroll/NMintro.mp4`** to intro serwisu, nie treść. Leży w tym samym HTML + i pasuje do naiwnego `\\.mp4`, a jako jedyny mp4 na `www.` zamiast `videos.`. +""" +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://www.naughtymachinima.com" +#: Tylko host plików, co samo w sobie odcina preroll z `www.naughtymachinima.com`. +_SOURCE_RE = re.compile( + r"]+src=[\"'](https?://videos\.naughtymachinima\.com/[^\"']+\.mp4)[\"'][^>]*?" + r"label=[\"']([^\"']{1,10})[\"']", + re.IGNORECASE, +) +_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) + + out: list[StreamSource] = [] + seen: set[str] = set() + for link, label in _SOURCE_RE.findall(html_text): + if not link or link in seen or "/preroll/" in link: + continue + seen.add(link) + out.append( + StreamSource( + link=link, + type="mp4", + quality=label.strip() or None, + referer=_BASE + "/", + # Zweryfikowane: 206 bez Referera, bez tokenu, faststart. + raw={"mobile_direct_ok": True}, + ) + ) + if not out: + log.info("naughtymachinima: brak mp4 na %s", page_url) + return None + + out.sort(key=lambda s: _QUALITY_RANK.get((s.quality or "").lower(), 99)) + return out