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:
parent
2ef54ad6ea
commit
b3e1092175
4 changed files with 253 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
186
app/connectors/direct_scrapers/pornmike.py
Normal file
186
app/connectors/direct_scrapers/pornmike.py
Normal 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,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
55
app/extractors/tubes/pornmike.py
Normal file
55
app/extractors/tubes/pornmike.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue