goon/app/connectors/direct_scrapers/pornmike.py
goon-foss b3e1092175 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>
2026-07-27 10:42:39 +02:00

186 lines
7.3 KiB
Python

"""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,
)
],
)