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>
186 lines
7.3 KiB
Python
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,
|
|
)
|
|
],
|
|
)
|