goon/app/connectors/xtapes.py
goon-foss 1555408e20 feat(movies): konektor xtapes.la (Full Movies przez REST API WordPressa)
Lista z /wp-json/wp/v2/posts (kategoria Full Movies, _embed=wp:term): tytul
z rokiem, studio z kategorii w nawiasie, gatunki, obsada z tagow. Ze strony
filmu odtwarzacze, dlugosc i okladka. Przez proxy, bo Cloudflare daje VPS-owi
403. Odtwarzacz sextapes.to rozwiazuje telefon (link zwiazany z IP), backend
go nie ekstrahuje; maxcloud (posrednik) pomijany. Tryb przyrostowy po after=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:10:16 +02:00

225 lines
9 KiB
Python

"""xtapes.la, konektor filmow (kategoria "Full Movies"). Dodany 2026-10-15.
WordPress z otwartym REST API, wiec liste bierzemy z `/wp-json/wp/v2/posts` z filtrem
kategorii, a nie z HTML-a. Ocena przed dodaniem (2026-10-15): 1846 filmow, 3 do 5 nowych
dziennie, czesto w dniu premiery; z 30 najnowszych 10 nie mielismy wcale, reszte z
yourdailypornmovies/paradisehill/mangoporn (xtapes dokleja im kolejny link).
Metadane z REST (`_embed=wp:term`):
- tytul "TYTUL (RRRR) - Full Movie" -> tytul i rok,
- studio to kategoria w nawiasie kwadratowym ("[EvilAngel]", "[Brazzers Movies]"),
- pozostale kategorie to gatunki (bez "Full Movies"),
- obsada to tagi (`post_tag`),
- opis z `content` (czesto pusty).
Ze strony filmu: odtwarzacze (iframe), dlugosc (`itemprop="duration"`), okladka (og:image).
Odtwarzacze: sextapes.to (P.A.C.K.E.R. + JWPlayer, HLS typu StreamWish) i maxcloud.fit.
maxcloud to posrednik (lista serwerow w base64, za nia kolejny serwis z blokada devtools),
wiec go pomijamy. Link sextapes jest zwiazany z IP rozwiazujacego (z VPS 403 z innego IP),
dlatego rozwiazuje go telefon (packerHoster.ts), a backend go nie ekstrahuje (playback.py).
Dostep: VPS dostaje od Cloudflare 403 na xtapes.la (REST i strony), przez Bright Data 200.
Proxy tylko do HTML/JSON, nie do wideo. sextapes.to z VPS odpowiada bez proxy.
"""
from __future__ import annotations
import html as html_mod
import json
import logging
import re
from collections.abc import Iterator
from datetime import datetime
from urllib.parse import urlparse
from app.config import get_settings
from app.connectors.base import (
BaseMovieConnector,
RawMovie,
RawPerformer,
RawPlaybackSource,
RawStudio,
RawTag,
)
from app.extractors import browser_get
from app.models.source import SourceKind
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://xtapes.la"
#: Id kategorii "Full Movies" (sprawdzone 2026-10-15; slugi kategorii sa zaciemnione liczbami).
_MOVIES_CATEGORY = 1766
_PER_PAGE = 30
_TITLE_RE = re.compile(
r"^(?P<title>.+?)\s*\((?P<year>(?:19|20)\d{2})\)\s*(?:[-–—]\s*Full\s+Movie)?\s*$",
re.IGNORECASE,
)
_FULL_MOVIE_TAIL_RE = re.compile(r"\s*[-–—]\s*Full\s+Movie\s*$", re.IGNORECASE)
_STUDIO_CAT_RE = re.compile(r"^\[(?P<name>.+)\]$")
_IFRAME_RE = re.compile(r"""<iframe[^>]+src=["']((?:https?:)?//[^"']+)["']""", re.IGNORECASE)
_DURATION_RE = re.compile(r'itemprop="duration"\s+content="P(?:\d+D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?"')
_OG_IMAGE_RE = re.compile(r"""property=["']og:image["']\s+content=["']([^"']+)""", re.IGNORECASE)
_TAGS_RE = re.compile(r"<[^>]+>")
#: Odtwarzacze, ktorych nie zapisujemy: posrednik bez wlasnego wideo i reklamy.
_SKIP_HOSTS = ("maxcloud", "realsrv", "magsrv", "exosrv", "juicyads", "google", "doubleclick")
def _host_label(url: str) -> str | None:
host = (urlparse(url).hostname or "").lower().removeprefix("www.")
if not host:
return None
parts = host.split(".")
return parts[-2] if len(parts) >= 2 else parts[0]
def parse_title(raw: str) -> tuple[str, int | None]:
"""'Tushy Raw V87 (2026) - Full Movie' -> ('Tushy Raw V87', 2026)."""
t = html_mod.unescape(raw).strip()
m = _TITLE_RE.match(t)
if m:
return m.group("title").strip(), int(m.group("year"))
return _FULL_MOVIE_TAIL_RE.sub("", t).strip(), None
def parse_duration(page: str) -> int | None:
m = _DURATION_RE.search(page)
if not m or not any(m.groups()):
return None
h, mi, s = (int(x) if x else 0 for x in m.groups())
total = h * 3600 + mi * 60 + s
return total or None
class XtapesConnector(BaseMovieConnector):
kind = SourceKind.scraper
name = "xtapes"
base_url = _BASE
_MAX_PAGES_DELTA = 3
_MAX_PAGES_FULL = 20
def __init__(self, *, timeout: float = 30.0) -> None:
self._timeout = timeout
self._proxy = get_settings().brightdata_proxy_url
def close(self) -> None:
pass
def _get(self, url: str):
"""xtapes.la od razu przez proxy (Cloudflare daje VPS-owi 403), bez proxy tylko gdy
go nie skonfigurowano (lokalnie z domowego IP strona odpowiada wprost)."""
r = browser_get(url, timeout=self._timeout, follow_redirects=True, proxy=self._proxy)
if r.status_code >= 400:
raise RuntimeError(f"{r.status_code} for {url}")
return r
def fetch_movies(
self, *, since: datetime | None = None, limit: int | None = None
) -> Iterator[RawMovie]:
seen = 0
max_pages = self._MAX_PAGES_DELTA if since is not None else self._MAX_PAGES_FULL
for page in range(1, max_pages + 1):
url = (
f"{_BASE}/wp-json/wp/v2/posts?categories={_MOVIES_CATEGORY}"
f"&per_page={_PER_PAGE}&page={page}&_embed=wp:term"
"&_fields=id,date,slug,link,title,content,_links,_embedded"
)
if since is not None:
# Tylko nowsze od ostatniego udanego przebiegu: strona filmu idzie przez proxy
# (~9 s), a 3 pelne strony REST to 90 filmow, ponad limit 6 min na konektor.
url += f"&after={since.strftime('%Y-%m-%dT%H:%M:%S')}"
try:
posts = json.loads(self._get(url).text)
except Exception as e:
# Za ostatnia strona WP oddaje 400 (rest_post_invalid_page_number).
log.info("%s REST page=%d: %s", self.name, page, e)
return
if not isinstance(posts, list) or not posts:
return
for post in posts:
try:
movie = self._movie_from_post(post)
except Exception as e:
log.warning("%s post %s failed: %s", self.name, post.get("link"), e)
continue
if movie is None:
continue
yield movie
seen += 1
if limit is not None and seen >= limit:
return
def _movie_from_post(self, post: dict) -> RawMovie | None:
link = (post.get("link") or "").strip()
title, year = parse_title((post.get("title") or {}).get("rendered", ""))
if not link or not title:
return None
studio: RawStudio | None = None
tags: list[RawTag] = []
performers: list[RawPerformer] = []
for group in (post.get("_embedded") or {}).get("wp:term") or []:
for term in group or []:
name = html_mod.unescape(term.get("name") or "").strip()
tax = term.get("taxonomy")
if not name:
continue
if tax == "category":
sm = _STUDIO_CAT_RE.match(name)
if sm:
sname = sm.group("name").strip()
if studio is None and slugify(sname):
studio = RawStudio(
external_id=f"{self.name}:studio:{slugify(sname)}",
name=sname,
slug=slugify(sname),
)
elif name.lower() != "full movies" and slugify(name):
tags.append(
RawTag(external_id=f"{self.name}:tag:{slugify(name)}", name=name, slug=slugify(name))
)
elif tax == "post_tag" and slugify(name):
performers.append(
RawPerformer(external_id=f"{self.name}:performer:{slugify(name)}", name=name)
)
page = self._get(link).text
# Playback najpierw: bez odtwarzacza film jest bezuzyteczny, nie tworzymy sieroty.
playback: list[RawPlaybackSource] = []
seen_host: set[str] = set()
for m in _IFRAME_RE.finditer(page):
embed = m.group(1)
if embed.startswith("//"):
embed = "https:" + embed
host = _host_label(embed)
if not host or host in seen_host or any(s in embed for s in _SKIP_HOSTS):
continue
seen_host.add(host)
playback.append(
RawPlaybackSource(origin=f"{self.name}:{host}", page_url=embed, embed_url=embed)
)
if not playback:
return None
desc = _TAGS_RE.sub(" ", (post.get("content") or {}).get("rendered") or "")
desc = re.sub(r"\s+", " ", html_mod.unescape(desc)).strip() or None
om = _OG_IMAGE_RE.search(page)
poster = html_mod.unescape(om.group(1)).strip() if om else None
return RawMovie(
external_id=post.get("slug") or urlparse(link).path.strip("/").split("/")[-1],
title=title,
description=desc,
release_year=year,
duration_sec=parse_duration(page),
url=link,
poster_url=poster,
studio=studio,
performers=performers,
tags=tags,
playback_sources=playback,
raw={"source": self.name, "url": link, "wp_id": post.get("id")},
)