diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index 1e94fc3..8861996 100644 --- a/app/connectors/direct_scrapers/__init__.py +++ b/app/connectors/direct_scrapers/__init__.py @@ -43,6 +43,7 @@ from app.connectors.direct_scrapers.youporn_browse import YouPornBrowseScraper from app.connectors.direct_scrapers.siska import SiskaScraper from app.connectors.direct_scrapers.sxyland import SxyLandScraper from app.connectors.direct_scrapers.sxyprn import SxyPrnScraper +from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper from app.connectors.direct_scrapers.watchporn import WatchPornScraper from app.connectors.direct_scrapers.youperv import YoupervScraper from app.connectors.direct_scrapers.xhamster import XHamsterScraper @@ -158,6 +159,12 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [ # z linków, duration + release_date ISO, ~60-70 scen/dzień. Playback: direct mp4 bez # tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera). YoupervScraper, + # GalaxyPornScraper — dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w + # wydzielonym `
` (zero pollution), studio+data z prefiksu + # tytułu, paginacja zdrowa. Duration liczone z thumbnail.vtt playera (nie ma go + # w HTML, a NULL ukryłby sceny pod filtrem min_duration_sec). Playback: iframe + # seekplayer → istniejący silnik, HLS przez /proxy/hls. + GalaxyPornScraper, # Browse równolegle do istniejącego search scrapera (wzorzec xvideos/eporner): # search zostaje (pokrycie back-catalogu performerów), browse gwarantuje świeżość # wprost z feedu (watchdog 48h zamiast 168h). Konwersja 2026-06-24 (user request). diff --git a/app/connectors/direct_scrapers/galaxyporn.py b/app/connectors/direct_scrapers/galaxyporn.py new file mode 100644 index 0000000..ae5e2b9 --- /dev/null +++ b/app/connectors/direct_scrapers/galaxyporn.py @@ -0,0 +1,228 @@ +"""galaxyporn.net — browse scraper (WordPress retrotube). Dodany 2026-07-26. + +Ripy paysite ze studiami (BrazzersExxtra, EvilAngel, NubilesPorn, RealityKings, +MYLF, BlackedRaw…). Ocena: orphan-risk LOW z twardym pomiarem — 92% performerów +z próbki 75 ma już u nas ref tpdb/stashdb, 79% tytułów matchuje istniejące sceny. + +Listing `/page/N/`, 21 scen/stronę, ~1180 stron, paginacja zdrowa (overlap p1/p2 = 0). + +Metadane: + - obsada: `
` — czysta, BEZ pollution (na całej stronie sceny + są dokładnie te 2-3 linki `/actor/`, nie ma sekcji Related z obcymi performerami) + - studio + data: prefiks tytułu `Studio YY MM DD Performer – Title` albo `[Studio]` + - tagi: `
` (wycinamy slug studia, żeby nie robić tag=studio) + - thumbnail: `data-main-thumb` / og:image + +**Duration — uwaga, nie ma go w HTML.** Ani listing, ani detail, ani JSON-LD, ani +payload playera nie niosą długości. NULL duration jest GROŹNY: `/scenes` filtruje +`duration_sec >= min_duration_sec`, a NULL w SQL odpada z wyniku, więc sceny byłyby +NIEWIDOCZNE w apce mimo "new: N, errors: 0" (dokładnie incydent porntrex, 6479 scen). +Dlatego liczymy ją z `thumbnail.vtt` playera (ostatni cue), 1 dodatkowy request na +scenę po odpytaniu API seekplayer. Wartość jest ~1 interwał miniatur krótsza od +realnej (2226 s vs ~2276 s na próbce, ~2%) — akceptowalne, bo alternatywą jest brak. + +Playback: iframe `#hash` rodziny seekplayer (galaxy.upns.online / galaxy.4meplayer.pro / +sport.seekplays.com / news.upns.pro) → extractor `galaxyporncom` → istniejący +`seekplayer_engine` (ten sam AES key/IV). HLS wymaga Referera → `/proxy/hls`. +""" +from __future__ import annotations + +import html +import json +import logging +import re +import time +from datetime import date +from urllib.parse import urlparse + +from app.connectors.base import ( + RawPerformer, + RawPlaybackSource, + RawScene, + RawStudio, + RawTag, +) +from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper, meta_content +from app.extractors._fetch import _DEFAULT_UA, browser_get +from app.normalize.text import slugify + +log = logging.getLogger(__name__) + +_BASE = "https://galaxyporn.net" + +_SCENE_URL_RE = re.compile(r']*title=', re.IGNORECASE) +_H1_RE = re.compile(r']*class="entry-title"[^>]*>(.*?)', re.IGNORECASE | re.DOTALL) +_ACTORS_BLOCK_RE = re.compile(r'
(.*?)
', re.IGNORECASE | re.DOTALL) +_ANCHOR_TEXT_RE = re.compile(r">([^<>]+)") +_TAGS_BLOCK_RE = re.compile(r'
(.*?)
', re.IGNORECASE | re.DOTALL) +_THUMB_RE = re.compile(r'data-main-thumb="([^"]+)"', re.IGNORECASE) +_IFRAME_RE = re.compile(r']+src="([^"]+)"', re.IGNORECASE) + +# `BrazzersExxtra 26 07 26 Kayley Gunner & Elly Clutch – Swapping Off The Pervy Maid` +_TITLE_STUDIO_DATE_RE = re.compile(r"^(?P[A-Za-z0-9][A-Za-z0-9._-]{1,30})\s+(?P\d{2})\s+(?P\d{2})\s+(?P\d{2})\s+(?P.+)$") +# `[Onlyfans] Some Title` / `[Darkkotv] …` +_TITLE_BRACKET_RE = re.compile(r"^\[(?P[^\]]{2,30})\]\s*(?P.+)$") +# `… [2018-09-28]` +_TITLE_ISO_DATE_RE = re.compile(r"\[(\d{4})-(\d{2})-(\d{2})\]") + +_VTT_CUE_RE = re.compile(r"(\d{2}):(\d{2}):(\d{2})[.,]\d+\s*-->\s*(\d{2}):(\d{2}):(\d{2})") + + +def _clean_text(raw: str) -> str: + text = re.sub(r"<[^>]+>", " ", raw) + return html.unescape(re.sub(r"\s+", " ", text)).strip() + + +def _resolve_duration(iframe_url: str, *, timeout: float, attempts: int = 3) -> int | None: + """Długość z `thumbnail.vtt` playera (ostatni cue). Patrz docstring modułu: + duration nie ma w HTML, a NULL ukrywa sceny pod filtrem `min_duration_sec`. + + API playera throttluje serie zapytań (przy ciągłym crawlu ~40% wywołań wracało + puste), więc retry z rosnącym backoffem. Każdy błąd → None (scena i tak wejdzie, + po prostu bez długości).""" + from app.extractors.hosters.seekplayer_engine import _decrypt, matches + + p = urlparse(iframe_url) + if not p.hostname or not matches(iframe_url): + return None + hash_id = p.fragment.strip() + if not hash_id: + return None + host = f"{p.scheme}://{p.hostname}" + headers = {"User-Agent": _DEFAULT_UA, "Accept": "*/*", "Referer": host + "/"} + + for attempt in range(attempts): + if attempt: + time.sleep(1.5 * attempt) + try: + r = browser_get( + f"{host}/api/v1/video?id={hash_id}&w=1920&h=1080&r=", + headers=headers, + timeout=timeout, + ) + if r.status_code != 200 or not r.text: + continue + data = json.loads(_decrypt(r.text)) + thumb = (data.get("thumbnail") or "").strip() + if not thumb: + return None # payload OK, ale brak miniatur — retry nie pomoże + vtt_url = host + thumb if thumb.startswith("/") else thumb + vr = browser_get(vtt_url, headers=headers, timeout=timeout) + if vr.status_code != 200: + continue + cues = _VTT_CUE_RE.findall(vr.text) + if not cues: + return None + h, m, s = cues[-1][3], cues[-1][4], cues[-1][5] + return (int(h) * 3600 + int(m) * 60 + int(s)) or None + except Exception as e: # pragma: no cover - best-effort + log.info("galaxyporn: duration attempt %d failed (%s): %s", attempt + 1, iframe_url[:50], e) + return None + + +class GalaxyPornScraper(BaseBrowseScraper): + sitetag = "galaxyporncom" + + def _listing_url(self, page: int) -> str: + return f"{_BASE}/" if page <= 1 else f"{_BASE}/page/{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 = m.group(1) + if url in seen or url.rstrip("/") == _BASE: + continue + seen.add(url) + out.append(url) + return out + + def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: + h1 = _H1_RE.search(detail_html) + title = _clean_text(h1.group(1)) if h1 else "" + if not title: + title = (meta_content(detail_html, property="og:title") or "").strip() + if not title: + return None + + # Obsada — blok wydzielony, bez scopingu na siłę (potwierdzone: cała strona + # ma dokładnie tyle linków /actor/ ile realnych aktorów). + performers: list[RawPerformer] = [] + seen_p: set[str] = set() + ab = _ACTORS_BLOCK_RE.search(detail_html) + if ab: + for m in _ANCHOR_TEXT_RE.finditer(ab.group(1)): + name = html.unescape(m.group(1)).strip() + 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) + ) + + # Studio + data z prefiksu tytułu. `Studio YY MM DD …` albo `[Studio] …`. + studio: RawStudio | None = None + release_date: date | None = None + studio_name: str | None = None + if (m := _TITLE_STUDIO_DATE_RE.match(title)): + studio_name = m.group("studio") + try: + release_date = date(2000 + int(m.group("y")), int(m.group("m")), int(m.group("d"))) + except ValueError: + release_date = None + elif (m := _TITLE_BRACKET_RE.match(title)): + studio_name = m.group("studio") + if release_date is None and (m := _TITLE_ISO_DATE_RE.search(title)): + try: + release_date = date(int(m.group(1)), int(m.group(2)), int(m.group(3))) + except ValueError: + release_date = None + # Guard: prefiks nie może być nazwiskiem performera (wtedy to nie studio). + if studio_name and slugify(studio_name) not in seen_p: + studio = RawStudio( + external_id=f"{self.sitetag}:studio:{slugify(studio_name)}", + name=studio_name, + slug=slugify(studio_name), + ) + + # Tagi — bez slugu studia (retrotube wrzuca studio także jako zwykły tag). + tags: list[RawTag] = [] + seen_t: set[str] = set() + studio_slug = slugify(studio_name) if studio_name else "" + tb = _TAGS_BLOCK_RE.search(detail_html) + if tb: + for m in _ANCHOR_TEXT_RE.finditer(tb.group(1)): + name = html.unescape(m.group(1)).strip() + sl = slugify(name) + if not sl or sl in seen_t or sl == studio_slug or sl in seen_p: + continue + seen_t.add(sl) + tags.append(RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl)) + + thumbnail_url = meta_content(detail_html, property="og:image") + if not thumbnail_url and (tm := _THUMB_RE.search(detail_html)): + thumbnail_url = tm.group(1) + + duration_sec: int | None = None + if (fm := _IFRAME_RE.search(detail_html)): + duration_sec = _resolve_duration(fm.group(1).strip(), timeout=self._timeout) + + return RawScene( + external_id=f"{self.sitetag}:{scene_url}", + title=title, + 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 57bfa37..b616e9d 100644 --- a/app/extractors/__init__.py +++ b/app/extractors/__init__.py @@ -30,6 +30,7 @@ from app.extractors.tubes import ( eporner, freshporno, fullmovies, + galaxyporn, hdporngg, hqfap, hqporner, @@ -108,6 +109,9 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = { # youperv — fluidplayer, zwykły mp4 na files.klubnichka-hd.com. Bez tokena # i bez IP-bindingu; CDN pilnuje tylko Referera (cross-IP 206 z VPS) → mobile direct. "youpervcom": youperv.extract, + # galaxyporn — iframe rodziny seekplayer (upns/4meplayer/seekplays); silnik już mamy, + # extractor tylko wyłuskuje iframe. HLS Referer+IP-bound → /proxy/hls. + "galaxyporncom": galaxyporn.extract, "siskavideo": _embed_iframe.extract, "porn4dayspw": _embed_iframe.extract, "porndishcom": _embed_iframe.extract, diff --git a/app/extractors/hosters/seekplayer_engine.py b/app/extractors/hosters/seekplayer_engine.py index 4e2d5e1..58c1279 100644 --- a/app/extractors/hosters/seekplayer_engine.py +++ b/app/extractors/hosters/seekplayer_engine.py @@ -54,13 +54,23 @@ log = logging.getLogger(__name__) _KEY = b"kiemtienmua911ca" _IV = b"1234567890oiuytr" -# Hostname matching: 6 base hosts × subdomains × TLD variants. +# Hostname matching: base hosts × subdomains × TLD variants. # Examples: # my.embedseek.online, vip.seekplayer.vip, my.rpmplay.online, # my.upns.online, vip.player4me.vip, p.easyvidplayer.com +# +# 2026-07-26: dołożone `seekplays|4meplayer|ezplayer|seeks` + TLD `pro|cloud|one`. +# Ta sama rodzina silnika (identyczny _KEY/_IV + `/api/v1/video?id=`), tylko inne +# domeny — bez tego regexu `matches()` odrzucał je zanim doszło do dekodowania. +# Zweryfikowane: galaxy.4meplayer.pro / sport.seekplays.com / news.upns.pro +# (galaxyporn) oraz av.ezplayer.me / av.seeks.cloud / 4k.upn.one (pornbusy) +# dekodują się tym samym silnikiem. Whitelist jest addytywna — istniejące hosty +# matchują się dalej tak samo. _HOST_RE = re.compile( - r"^(?:[a-z0-9]+\.)?(?:embedseek|seekplayer|rpmplay|upns|player4me|easyvidplayer)\." - r"(?:online|vip|com|net|io|me|tv)$", + r"^(?:[a-z0-9]+\.)?" + r"(?:embedseek|seekplayer|seekplays|seeks|rpmplay|upns|upn|player4me|4meplayer" + r"|ezplayer|easyvidplayer)\." + r"(?:online|vip|com|net|io|me|tv|pro|cloud|one)$", re.IGNORECASE, ) diff --git a/app/extractors/tubes/galaxyporn.py b/app/extractors/tubes/galaxyporn.py new file mode 100644 index 0000000..ad06ac1 --- /dev/null +++ b/app/extractors/tubes/galaxyporn.py @@ -0,0 +1,69 @@ +"""galaxyporn.net — iframe → seekplayer engine. Dodany 2026-07-26. + +Scene page ma jeden `