diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index 1f40a6e..6b7fd94 100644 --- a/app/connectors/direct_scrapers/__init__.py +++ b/app/connectors/direct_scrapers/__init__.py @@ -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 diff --git a/app/connectors/direct_scrapers/pornmike.py b/app/connectors/direct_scrapers/pornmike.py new file mode 100644 index 0000000..bd3ab8a --- /dev/null +++ b/app/connectors/direct_scrapers/pornmike.py @@ -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: `` 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, + ) + ], + ) diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py index 9e7c805..3cac7e7 100644 --- a/app/extractors/__init__.py +++ b/app/extractors/__init__.py @@ -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 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. diff --git a/app/extractors/tubes/pornmike.py b/app/extractors/tubes/pornmike.py new file mode 100644 index 0000000..e953e30 --- /dev/null +++ b/app/extractors/tubes/pornmike.py @@ -0,0 +1,55 @@ +"""pornmike.com — direct mp4 z ``. Dodany 2026-07-27. + +Najprostszy stream w portfolio: zwykły video.js na natywnym `