feat(pornmike): browse scraper behind a cast gate + direct-mp4 extractor

pornmike.com puts everything in JSON-LD @graph -> ItemPage.mainEntity
(name, duration, uploadDate, actor[], genre[], keywords, description,
thumbnail), so the parser reads one JSON blob instead of scraping markup.
Cast is clean: the whole scene page carries exactly as many /pornstar/
links as there are actors, none of the sidebar pollution that got xxxfiles
rejected. 82% of sampled performers already carry a tpdb/stashdb ref and
19-20 of 20 channels are studios we already know.

Ingest is gated on a non-empty actor[]: 23% of the catalog has no cast and
those scenes could neither be attributed nor deduped. Tags and categories
come only from JSON-LD (keywords + genre), never from the HTML, which
carries 38 /category/ and 22 /tag/ nav and sidebar links per page.
Pagination is ?p=N only: the /N/ form 404s and ?page=N is silently ignored,
returning page 1.

Playback is the simplest in the portfolio: a plain <source> mp4 on twincdn
with no token, no query string and no expiry. Verified 206 on a Range
request from the VPS and from another machine in another country, both
without a Referer, so it is neither hotlink-guarded nor IP-bound and the
phone streams it directly.

Two honest caveats recorded in the module docstring: these are 5-12 minute
clips (median ~487s against 1800-2400s for the tubes we accepted), so for
the ~20% of the catalog from Tushy/Blacked Raw/Milfy/Anilos they will sit
as a short shadow next to full canonical scenes; and uploadDate is the
tube's import date, not the studio release, so pages past the second are
marked backfill.

Pilot ingest of 3 pages: 127 seen, 49 merged into existing scenes, 66 new,
0 errors; on page one the gate passed 42 of 62 links with zero scenes
missing cast, duration or studio.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-07-27 10:42:39 +02:00
parent 2ef54ad6ea
commit b3e1092175
4 changed files with 253 additions and 0 deletions

View file

@ -46,6 +46,7 @@ from app.connectors.direct_scrapers.sxyprn import SxyPrnScraper
from app.connectors.direct_scrapers.fullvideosporn import FullVideosPornScraper
from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper
from app.connectors.direct_scrapers.pornbusy import PornBusyScraper
from app.connectors.direct_scrapers.pornmike import PornMikeScraper
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
from app.connectors.direct_scrapers.youperv import YoupervScraper
from app.connectors.direct_scrapers.xhamster import XHamsterScraper
@ -172,6 +173,13 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
PornBusyScraper,
# PornMikeScraper — dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD
# VideoObject, obsada bez pollution (1 link = realna obsada), 82% performerów z
# refem tpdb/stashdb, 19-20/20 kanałów znamy jako studia. Bramka na obsadę (23%
# katalogu bez actor[]). Tagi TYLKO z JSON-LD (HTML ma 38+22 linków nav/sidebar).
# UWAGA: to klipy 5-12 min, nie pełne sceny — ~80% katalogu to jednak studia bez
# żadnego pokrycia u nas, czyli nowa podaż. Playback: gołe mp4, zero proxy.
PornMikeScraper,
# FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
# = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
# katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko

View file

@ -0,0 +1,186 @@
"""pornmike.com — browse scraper (CodeIgniter, JSON-LD). Dodany 2026-07-27.
Ripy paysite (Tushy, Blacked Raw, Milfy, Deep Lush, Anilos) + nisze bez pokrycia
u nas (Jeffs Models, Golden Slut, Teen Erotica, Red-XXX, Erotik von Nebenan).
Pomiar: 82% performerów ma u nas ref tpdb/stashdb, 19-20/20 kanałów znamy jako studia.
Metadane w CAŁOŚCI z JSON-LD `@graph` `ItemPage.mainEntity` (VideoObject): name,
duration, uploadDate, actor[], genre[], keywords, description, thumbnailUrl. Zero
`og:`, zero itemprop więc nie ma ryzyka AI-spinu w og:title (choć sam tytuł i tak
jest przepisany pod SEO, patrz niżej).
**Świadome decyzje:**
- **Bramka na obsadę** (23% katalogu bez `actor[]`). Te sceny nie mają jak się
zdedupować ani zaatrybuować, weszłyby jako puste orphany. Kosztuje mało, w
przeciwieństwie do fullvideosporn (tam 65-75%).
- **Tagi i kategorie TYLKO z JSON-LD**, nigdy z HTML: strona ma 38 linków
`/category/` i 22 `/tag/` w nawigacji i sidebarze. `genre[]` daje 3-4 scoped,
`keywords` 7-8 scoped.
- **`release_date` = uploadDate, ale to data importu na tubie, nie premiera studia**
(najstarsze sceny mają uploadDate z 2025-10). Dlatego `backfill=True` dla stron > 2
zgodnie z regułą `scenes.backfill`, żeby stary katalog nie udawał nowości.
- Paginacja: WYŁĄCZNIE `?p=N`. Forma `/newest-porn-videos/2/` daje 404, a `?page=2`
jest ignorowane (zwraca stronę 1). Na każdej stronie jest stały 10-elementowy blok
sidebara (stąd 62 linki, realnie 52 sceny) dedup po external_id go pochłania.
- Ingest tylko EN (`/videos/`), pomijamy niemiecki mirror (`/de/filme/`).
**Uwaga jakościowa:** to KLIPY 5-12 min (mediana ~487 s) wobec 1800-2400 s w tubach,
które przyjęliśmy. Dla ~20% katalogu (Tushy/Blacked Raw/Milfy/Anilos) będą cieniem
obok pełnych scen kanonicznych. Pozostałe ~80% to studia bez żadnego pokrycia u nas,
czyli realnie nowa podaż.
Playback: `<source>` direct mp4 (twincdn) extractor `pornmike`, bez tokenu, bez
Referera, przenośny cross-IP.
"""
from __future__ import annotations
import json
import logging
import re
from app.connectors.base import (
RawPerformer,
RawPlaybackSource,
RawScene,
RawStudio,
RawTag,
)
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.connectors.direct_scrapers._playtube import _parse_iso_date, _parse_iso_duration
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://pornmike.com"
_SCENE_URL_RE = re.compile(r'href="(?:https://pornmike\.com)?(/videos/[a-z0-9-]+/)"', re.IGNORECASE)
_JSONLD_RE = re.compile(
r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.IGNORECASE | re.DOTALL
)
_CHANNEL_RE = re.compile(
r'href="(?:https://pornmike\.com)?/channel/([a-z0-9-]+)/"[^>]*>([^<]*)', re.IGNORECASE
)
_SCENE_ID_RE = re.compile(r"-(\d+)/?$")
def _humanize(slug: str) -> str:
return " ".join(w.capitalize() if w.islower() else w for w in slug.split("-") if w)
def _video_object(html_text: str) -> dict | None:
"""JSON-LD `@graph` → pierwszy VideoObject (zwykle jako `ItemPage.mainEntity`)."""
for m in _JSONLD_RE.finditer(html_text):
try:
data = json.loads(m.group(1).strip())
except (json.JSONDecodeError, ValueError):
continue
nodes = data.get("@graph") if isinstance(data, dict) else data
if isinstance(nodes, dict):
nodes = [nodes]
for node in nodes or []:
if not isinstance(node, dict):
continue
if node.get("@type") == "VideoObject":
return node
main = node.get("mainEntity")
if isinstance(main, dict) and main.get("@type") == "VideoObject":
return main
return None
class PornMikeScraper(BaseBrowseScraper):
sitetag = "pornmike"
def _listing_url(self, page: int) -> str:
# Tylko `?p=N`: `/newest-porn-videos/N/` → 404, `?page=N` → ignorowane.
base = f"{_BASE}/newest-porn-videos/"
return base if page <= 1 else f"{base}?p={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for m in _SCENE_URL_RE.finditer(listing_html):
url = _BASE + m.group(1)
if url in seen:
continue
seen.add(url)
out.append(url)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
vo = _video_object(detail_html)
if not vo:
log.info("pornmike: brak JSON-LD VideoObject na %s", scene_url)
return None
# BRAMKA: bez obsady scena nie ma jak się zaatrybuować ani zdedupować.
performers: list[RawPerformer] = []
seen_p: set[str] = set()
for a in vo.get("actor") or []:
name = (a.get("name") or "").strip() if isinstance(a, dict) else ""
sl = slugify(name)
if not sl or sl in seen_p:
continue
seen_p.add(sl)
performers.append(
RawPerformer(external_id=f"{self.sitetag}:performer:{sl}", name=name)
)
if not performers:
return None
title = (vo.get("name") or "").strip()
if not title:
return None
duration_sec = _parse_iso_duration(vo.get("duration"))
release_date = _parse_iso_date(vo.get("uploadDate"))
description = (vo.get("description") or "").strip() or None
thumbnail_url = (vo.get("thumbnailUrl") or "").strip() or None
# Tagi: keywords + ostatni segment każdego URL-a z genre[]. NIGDY z HTML
# (38 linków /category/ + 22 /tag/ w nav i sidebarze).
tags: list[RawTag] = []
seen_t: set[str] = set()
names: list[str] = [k.strip() for k in (vo.get("keywords") or "").split(",")]
for g in vo.get("genre") or []:
if isinstance(g, str):
names.append(_humanize(g.rstrip("/").rsplit("/", 1)[-1]))
for name in names:
sl = slugify(name)
if not sl or sl in seen_t or sl in seen_p:
continue
seen_t.add(sl)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl))
studio: RawStudio | None = None
cm = _CHANNEL_RE.search(detail_html)
if cm:
sname = (cm.group(2) or "").strip() or _humanize(cm.group(1))
if slugify(sname) not in seen_p:
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(sname)}",
name=sname,
slug=slugify(sname),
)
id_m = _SCENE_ID_RE.search(scene_url.rstrip("/"))
scene_id = id_m.group(1) if id_m else scene_url
return RawScene(
external_id=f"{self.sitetag}:{scene_id}",
title=title,
description=description,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -41,6 +41,7 @@ from app.extractors.tubes import (
paradisehill,
porn00,
pornbusy,
pornmike,
porntrex,
supjav,
sxyprn,
@ -118,6 +119,9 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
# rodzina seekplayer (istniejący silnik), zpi.cx (embedURL to gotowy plik).
# upload18 + ogon → None (token IP-bound, resolve wymusiłby proxy całego wideo).
"pornbusycom": pornbusy.extract,
# pornmike — gołe <source> mp4 na twincdn: bez tokenu, bez Referera, bez expiry.
# Cross-IP 206 z VPS i z innego kraju → mobile gra direct, zero proxy/WebView.
"pornmike": pornmike.extract,
# fullvideosporn (= sextu) — TXXX: videofile.php → dekod → get_file → 302 → znvcdn.
# Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429
# (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy.

View file

@ -0,0 +1,55 @@
"""pornmike.com — direct mp4 z `<source>`. Dodany 2026-07-27.
Najprostszy stream w portfolio: zwykły video.js na natywnym `<video>`, dwie rendycje
(720p + 360p) na `*.twincdn.com`, **bez tokenu, bez query stringa, bez expiry**.
Zweryfikowane cross-IP (Range bytes=0-1023): 206 z VPS BEZ Referera, 206 z innej
maszyny w innym kraju również bez Referera ani hotlink-guard, ani IP-binding.
Pierwszy KB zawiera `ftyp` + `moov` (faststart), więc seek działa od razu. Link
nie gnije: scena sprzed 9 miesięcy nadal oddaje 206.
Stąd `mobile_direct_ok` telefon gra prosto z CDN, zero proxy i zero WebView.
Uwaga na fałszywy trop przy diagnozie: strona ma `m3u8` + `hls.js`, ale to widget
reklamowy live-cam (saawsedge/stripchat), nie ma nic wspólnego ze sceną.
"""
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://pornmike.com"
_SOURCE_RE = re.compile(
r'<source\s+src="([^"]+\.mp4)"[^>]*data-res="(\d+)"', re.IGNORECASE
)
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html_text = fetch_tube_html(page_url, timeout=timeout)
seen: set[str] = set()
out: list[StreamSource] = []
for m in _SOURCE_RE.finditer(html_text):
url, res = m.group(1), m.group(2)
if url in seen:
continue
seen.add(url)
out.append(
StreamSource(
link=url,
type="mp4",
quality=f"{res}p",
# Referer zbędny (CDN go nie sprawdza), ustawiamy dla higieny.
referer=_BASE + "/",
raw={"mobile_direct_ok": True},
)
)
if not out:
log.info("pornmike: brak <source> mp4 na %s", page_url)
return None
out.sort(key=lambda s: int((s.quality or "0p")[:-1]), reverse=True)
return out