goon/app/connectors/direct_scrapers/naughtymachinima.py
goon-foss 1c1e46130a 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 <source> 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 <source src=""> (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.
2026-08-13 09:06:24 +02:00

148 lines
5.9 KiB
Python

"""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<N>.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 `<source>` 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"<h1[^>]*>\s*([^<]{2,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'property="og:image"[^>]*content="([^"]{10,300})"')
_TAG_RE = re.compile(r'<a class="tag"[^>]*href="[^"]*/tags/[^"]+"[^>]*>\s*([^<]{1,60}?)\s*</a>')
#: Autor: `<a href="/user/Zector95"><img …/><span>Zector95</span>`.
_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,
)
],
)