diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py index 5fbf5cc..7a19920 100644 --- a/app/extractors/__init__.py +++ b/app/extractors/__init__.py @@ -35,6 +35,8 @@ from app.extractors.tubes import ( hdporngg, hqfap, perverzija, + porndish, + fpoxxx, hqporner, fullporner, hentaigasm, @@ -106,7 +108,8 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = { # fpoxxx, KVS, plain get_file + license. 2026-06-01 (task #20): get_file 302 → # `videos3.fpo.xxx/remote_control.php?acctoken=`, zdekodowany acctoken # zawiera WBITY IP serwera-resolvera → definitywnie IP-bound. WebView only. - "fpoxxx": _vps_blocked_fallback.extract, + # Skasowany film = HosterDead zamiast strony z reklama w WebView (2026-10-15). + "fpoxxx": fpoxxx.extract, # sxyland, embeduje playmogo.com/e/ (= klon DoodStream: doodcdn.io + pass_md5 # + niewidzialny CF Turnstile; Chrome-DevTools verify 2026-06-08, bug-report 827a50a1). # Strona sxyland NIE jest Turnstile-gated (VPS curl wyciąga iframe URL z HTML), więc @@ -177,7 +180,8 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = { "fullvideosporn": fullvideosporn.extract, "siskavideo": _embed_iframe.extract, "porn4dayspw": _embed_iframe.extract, - "porndishcom": _embed_iframe.extract, + # Wlasny ekstraktor: obejscie cache serwisu, ktory oddawal cudze wpisy (2026-10-15). + "porndishcom": porndish.extract, # xxxfreewatch, DELISTED 2026-05-18. 790 solo-orphan scen, 0% match, CF-walled z VPS. "latestleaksco": _embed_iframe.extract, "mypornerleakcom": _embed_iframe.extract, diff --git a/app/extractors/hoster.py b/app/extractors/hoster.py index 19e2429..64d6166 100644 --- a/app/extractors/hoster.py +++ b/app/extractors/hoster.py @@ -113,6 +113,10 @@ _HOSTER_FALLBACK_URL_RE = re.compile( # Sygnatury "video not found" / "deleted" które hostery wstawiają w HTML embed page. # Gdy widzimy te markery, to wiemy że link jest martwy, raise HosterDead, caller w # playback.py oznaczy playback_source.dead_at. +_NOWPLAY_INNER_RE = re.compile( + r"""]+src=["']((?:https?:)?//[^"']*nowplay\.[a-z]+/emb[^"']*)["']""", re.IGNORECASE +) + _HOSTER_DEAD_PATTERNS = ( "Video not found", "video not found", @@ -266,6 +270,15 @@ def extract_stream_from_hoster( return None html = r.text + # nowplay.to (sxyland od 10.2026): gdy plik skasowano, strona emb nie ma packera, tylko + # ramke do INNEGO wideo nowplay (57-minutowy zastepczy film, wspolny dla wszystkich + # skasowanych). Bez tego telefon dostawal hoster i gral cudzy film w WebView. + if "nowplay." in iframe_url.lower() and "eval(function(p,a,c,k,e" not in html: + inner = _NOWPLAY_INNER_RE.findall(html) + own = iframe_url.split("//", 1)[-1].rstrip("/") + if inner and all(i.split("//", 1)[-1].rstrip("/") != own for i in inner): + raise HosterDead(f"hoster {iframe_url}: plik usuniety, nowplay podstawia zastepczy film") + if any(p in html for p in _HOSTER_DEAD_PATTERNS): raise HosterDead(f"hoster {iframe_url} reports video deleted/not found") diff --git a/app/extractors/tubes/_embed_iframe.py b/app/extractors/tubes/_embed_iframe.py index 87880a2..2fec800 100644 --- a/app/extractors/tubes/_embed_iframe.py +++ b/app/extractors/tubes/_embed_iframe.py @@ -26,6 +26,7 @@ from urllib.parse import urlparse from app.extractors._fetch import fetch_tube_html from app.extractors._models import StreamSource from app.extractors.hoster import extract_stream_from_hoster +from app.extractors._models import HosterDead log = logging.getLogger(__name__) @@ -253,6 +254,11 @@ def _is_player_iframe(url: str) -> bool: return False +_PREVIEW_ATTR_RE = re.compile( + r"""\sdata-(?:trailer|preview)[\w-]*\s*=\s*(?:"[^"]*"|'[^']*')""", re.IGNORECASE +) + + def _extract_direct_stream_urls(page_html: str, page_url: str) -> list[StreamSource]: """Stage 0.5: scan page for direct mp4/m3u8 URLs (porn4days→iceyfile/gounlimited download links, xmoviesforyou native player). Returns deduplicated sources @@ -272,6 +278,11 @@ def _extract_direct_stream_urls(page_html: str, page_url: str) -> list[StreamSou page_host = (urlparse(page_url).hostname or "").lstrip("www.") page_referer = f"https://{page_host}/" if page_host else page_url + # Kafelki "podobne" maja zwiastuny w data-trailer/data-preview; sxyland oddawal + # 25-sekundowy cudzy trailer (gammacdn .../tr_..._720p.mp4) jako film sceny, losowo, + # bo kafelki zmieniaja sie przy kazdym pobraniu (zgloszenie 23269128, 2026-10-12). + page_html = _PREVIEW_ATTR_RE.sub("", page_html) + for m in _DIRECT_MP4_RE.finditer(page_html): url = m.group("url") if url in seen: @@ -290,6 +301,8 @@ def _extract_direct_stream_urls(page_html: str, page_url: str) -> list[StreamSou low = url.lower() if "videos_screenshots/" in low or "/preview_" in low: continue + if "/trailers/" in low or "//trailers-" in low or "/trailer/" in low: + continue if "/get_file/" in low and "?" not in url: continue # Quality wykrycie z nazwy pliku @@ -322,8 +335,12 @@ def extract( page_url: str, *, timeout: float = 60.0, + page_html: str | None = None, ) -> list[StreamSource] | None: - page_html = fetch_tube_html(page_url, timeout=timeout) + # `page_html` podaje ekstraktor, ktory musial pobrac strone po swojemu (porndish: + # obejscie zepsutego cache serwisu i kontrola, czy to wlasciwy wpis). + if page_html is None: + page_html = fetch_tube_html(page_url, timeout=timeout) # Stage 0.5: direct .mp4/.m3u8 URLs on page (porn4days→iceyfile, niektóre xmoviesforyou # native players). Wymagamy quality marker w path (`p.mp4`) żeby uniknąć @@ -482,11 +499,20 @@ def extract( # URL z embed page, ale ani VPS proxy ani mobile go nie pobierze (No route to host # albo 403). Bez iframe fallbacku mobile dostaje 503 i utyka. Z fallbackiem chain: # direct → proxy → iframe1 (WebView) → iframe2 (WebView) → page (WebView). + # Ramki, o ktorych hoster mowi wprost "plik usuniety" w sposob pewny (na razie tylko + # zastepczy film nowplay). Ogolnego HosterDead tu nie liczymy: strony DoodStream maja + # ukryty blok "not found" w kazdym HTML-u. + dead_iframes: set[str] = set() for idx, iframe_url in enumerate(iframe_urls): try: stream_url = extract_stream_from_hoster( iframe_url, referer=referer, timeout=timeout, ) + except HosterDead as e: + if "nowplay." in iframe_url.lower(): + dead_iframes.add(iframe_url) + log.info("embed_iframe: %s", e) + continue except Exception as e: log.warning("embed_iframe: extract failed for %s: %s", iframe_url, e) continue @@ -508,7 +534,7 @@ def extract( # jako hoster fallback. WebView załaduje embed page z własną sesją/IP/cookies, # może pobrać video gdy VPS proxy blocked. for u in iframe_urls: - if u == iframe_url: + if u == iframe_url or u in dead_iframes: continue host_label = (urlparse(u).hostname or "").lstrip("www.").split(".")[0] or None sources.append(StreamSource(link=u, type="hoster", quality=host_label)) @@ -525,8 +551,12 @@ def extract( # vs "luluvid"). Na końcu page_url jako safety-net, gdy wszystkie iframe'y są # martwe (np. sxyland → xtapes.porn 301→camcaps.to 403), mobile WebView otworzy # główną stronę aggregator tube'a, gdzie user może wybrać alternatywny player. + if iframe_urls and all(u in dead_iframes for u in iframe_urls): + raise HosterDead(f"wszystkie odtwarzacze na {page_url} maja usuniety plik") sources = [] for u in iframe_urls: + if u in dead_iframes: + continue host_label = (urlparse(u).hostname or "").lstrip("www.").split(".")[0] or None sources.append(StreamSource(link=u, type="hoster", quality=host_label)) page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page" diff --git a/app/extractors/tubes/fpoxxx.py b/app/extractors/tubes/fpoxxx.py new file mode 100644 index 0000000..c03e357 --- /dev/null +++ b/app/extractors/tubes/fpoxxx.py @@ -0,0 +1,31 @@ +"""fpo.xxx: kontrola usunietego filmu przed oddaniem strony do WebView. + +Odtwarzanie idzie na telefonie (`fpoxxxResolver.ts`, KVS license_code z IP telefonu). Gdy +telefon nic nie znajdzie, backend oddawal sama strone jako hoster. Na stronie skasowanego +filmu (`
`, "Deleted by the owner request", bez license_code) jedynym +wideo jest reklama tsyndicate, ktora WebView wylapywal i puszczal jako scene (zgloszenie +1ee12943, 2026-10-12: "fpoxxx daje reklamy zamiast scen"; ok. 10% zrodel fpoxxx to takie +strony). Tu taka strona to HosterDead, wiec zrodlo znika zamiast grac reklame. +""" +from __future__ import annotations + +import logging + +from app.extractors._fetch import fetch_tube_html +from app.extractors._models import HosterDead, StreamSource, TubePageError +from app.extractors.tubes import _vps_blocked_fallback + +log = logging.getLogger(__name__) + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource]: + try: + html = fetch_tube_html(page_url, timeout=min(timeout, 20.0)) + except TubePageError: + raise + except Exception as e: + log.info("fpoxxx: strona %s niepobrana (%s), oddaje do WebView", page_url, e) + return _vps_blocked_fallback.extract(page_url, timeout=timeout) + if 'class="no-player"' in html and "license_code" not in html: + raise HosterDead(f"fpoxxx {page_url}: film usuniety (no-player)") + return _vps_blocked_fallback.extract(page_url, timeout=timeout) diff --git a/app/extractors/tubes/porndish.py b/app/extractors/tubes/porndish.py new file mode 100644 index 0000000..d7189f2 --- /dev/null +++ b/app/extractors/tubes/porndish.py @@ -0,0 +1,52 @@ +"""porndish.com: strona sceny z obejsciem zepsutego cache serwisu, potem embed-iframe. + +Zgloszenie 88085cda (2026-10-12, "nic z porndish nie dziala"): cache strony porndish +(`x-cache-handler: autoptimize-pro-cache`, `cf-cache-status: HIT`) oddaje dla kazdego +`/porn//` CUDZY wpis. Nowsze adresy dostawaly "[DadCrush] Carly Kiss", starsze +"[MyLifeInMiami] ...", wiec kazda z ~40 tys. scen porndish grala jeden z dwoch tych samych +filmow. Telemetria liczyla to jako sukces (wideo ruszalo, tylko nie to). + +Dowolny parametr w zapytaniu omija cache (`BYPASS`), wiec dokladamy `?nc=` +(jeden wariant na godzine, zeby nie mnozyc wpisow w ich cache). Do tego kontrola: kanoniczny +adres strony musi miec ten sam slug co zadany, inaczej nie oddajemy niczego (502), bo +cudzy film jest gorszy niz brak filmu. +""" +from __future__ import annotations + +import logging +import re +import time +from urllib.parse import urlsplit + +from app.extractors._fetch import fetch_tube_html +from app.extractors._models import StreamSource, TubePageError +from app.extractors.tubes import _embed_iframe + +log = logging.getLogger(__name__) + +_CANONICAL_RE = re.compile( + r"""]+rel=["']canonical["'][^>]+href=["']([^"']+)["']""" + r"""|]+property=["']og:url["'][^>]+content=["']([^"']+)["']""", + re.IGNORECASE, +) + + +def _slug(url: str) -> str: + return urlsplit(url).path.rstrip("/").rsplit("/", 1)[-1].lower() + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + sep = "&" if "?" in page_url else "?" + busted = f"{page_url}{sep}nc={int(time.time() // 3600)}" + html = fetch_tube_html(busted, timeout=timeout) + + want = _slug(page_url) + m = _CANONICAL_RE.search(html) + got = _slug(m.group(1) or m.group(2)) if m else "" + if want and got and got != want: + log.warning("porndish: %s oddal cudzy wpis (%s)", page_url, got) + raise TubePageError(502, page_url) + + # Adres z `nc` takze dla zapasu "cala strona w WebView": bez niego telefon tez dostalby + # cudzy wpis z cache. + return _embed_iframe.extract(busted, timeout=timeout, page_html=html)