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.
This commit is contained in:
parent
2939e03d59
commit
1c1e46130a
4 changed files with 223 additions and 0 deletions
|
|
@ -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.hentaimama import HentaiMamaScraper
|
||||||
from app.connectors.direct_scrapers.hentaigasm import HentaiGasmScraper
|
from app.connectors.direct_scrapers.hentaigasm import HentaiGasmScraper
|
||||||
from app.connectors.direct_scrapers.hentaiplay import HentaiPlayScraper
|
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.rule34video import Rule34VideoScraper
|
||||||
from app.connectors.direct_scrapers.xanimeporn import XAnimePornScraper
|
from app.connectors.direct_scrapers.xanimeporn import XAnimePornScraper
|
||||||
from app.connectors.direct_scrapers.fullporner import FullPornerScraper
|
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,
|
# (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.
|
# najwiecej w calym wertykale. Playback: mp4 wprost w HTML, bez CF i bez proxy.
|
||||||
HentaiPlayScraper,
|
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).
|
# Rule34VideoScraper — dodany 2026-08-13, pierwsze zrodlo 3D (SFM/Blender).
|
||||||
# Bramka >=60s odrzuca ~47% katalogu (mediana 68s), bo apka i tak filtruje
|
# Bramka >=60s odrzuca ~47% katalogu (mediana 68s), bo apka i tak filtruje
|
||||||
# ponizej minuty. Tagi TYLKO z class=tag_item, `class=button` to chmura.
|
# ponizej minuty. Tagi TYLKO z class=tag_item, `class=button` to chmura.
|
||||||
|
|
|
||||||
148
app/connectors/direct_scrapers/naughtymachinima.py
Normal file
148
app/connectors/direct_scrapers/naughtymachinima.py
Normal file
|
|
@ -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<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,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
@ -40,6 +40,7 @@ from app.extractors.tubes import (
|
||||||
hentaigasm,
|
hentaigasm,
|
||||||
hentaiplay,
|
hentaiplay,
|
||||||
hentaimama,
|
hentaimama,
|
||||||
|
naughtymachinima,
|
||||||
rule34video,
|
rule34video,
|
||||||
xanimeporn,
|
xanimeporn,
|
||||||
kporner8,
|
kporner8,
|
||||||
|
|
@ -150,6 +151,9 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
||||||
# xanimeporn — Fluid Player z PUSTYM <source>, wideo wstrzykiwane w runtime.
|
# xanimeporn — Fluid Player z PUSTYM <source>, wideo wstrzykiwane w runtime.
|
||||||
# Wyjście: zakładka Downloads, czyli videos.xanimeporn.com/download.php?id=<postId>.
|
# Wyjście: zakładka Downloads, czyli videos.xanimeporn.com/download.php?id=<postId>.
|
||||||
# Oddajemy download.php, nie docelowy plik, żeby token generował się przy odtwarzaniu.
|
# Oddajemy download.php, nie docelowy plik, żeby token generował się przy odtwarzaniu.
|
||||||
|
# naughtymachinima — jawne <source> 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
|
# rule34video — KVS z JAWNYMI flashvars (4 jakosci), ale `v-acctoken` jest
|
||||||
# ZWIAZANY Z IP tego, kto pobral strone (test krzyzowy: proxy→VPS = 403).
|
# ZWIAZANY Z IP tego, kto pobral strone (test krzyzowy: proxy→VPS = 403).
|
||||||
# Dlatego BEZ mobile_direct_ok: telefon resolwuje sam, upfront, przez
|
# Dlatego BEZ mobile_direct_ok: telefon resolwuje sam, upfront, przez
|
||||||
|
|
|
||||||
67
app/extractors/tubes/naughtymachinima.py
Normal file
67
app/extractors/tubes/naughtymachinima.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""naughtymachinima.com — jawne `<source>` 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:
|
||||||
|
|
||||||
|
<source src="https://videos.naughtymachinima.com/media/videos/h264/121144_1080p.mp4"
|
||||||
|
default type='video/mp4' label='1080p' res='1080'/>
|
||||||
|
|
||||||
|
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 `<source src="">`** 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"<source[^>]+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 <source> mp4 na %s", page_url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
out.sort(key=lambda s: _QUALITY_RANK.get((s.quality or "").lower(), 99))
|
||||||
|
return out
|
||||||
Loading…
Add table
Reference in a new issue