"""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: `` 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']+application/ld\+json[^>]*>(.*?)', 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, ) ], )