diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index 1f075e4..1f40a6e 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.fullvideosporn import FullVideosPornScraper from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper from app.connectors.direct_scrapers.pornbusy import PornBusyScraper from app.connectors.direct_scrapers.watchporn import WatchPornScraper @@ -171,6 +172,12 @@ 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, + # 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 + # sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas). + # Tytuł z `vit` playera (og:title to AI-spin SEO), bramka cookie 429 na fetchach. + FullVideosPornScraper, # 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/fullvideosporn.py b/app/connectors/direct_scrapers/fullvideosporn.py new file mode 100644 index 0000000..8ce4867 --- /dev/null +++ b/app/connectors/direct_scrapers/fullvideosporn.py @@ -0,0 +1,204 @@ +"""fullvideosporn.com (= sextu.com pod nową marką) — browse scraper z BRAMKĄ NA OBSADĘ. + +To NIE jest klon fullmovies.xxx: inny silnik (TXXX vs KVS), inna taksonomia, 0/140 +pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`. + +**BRAMKA (warunek konieczny)**: 65-75% katalogu NIE MA żadnego performera. Sceny bez +obsady mają dodatkowo mocno spunowane tytuły ("Fabulous Porn Movie Gothic Newest +Exclusive Version"), więc nie zmatchują canonical i weszłyby jako puste orphany. +Ingestujemy WYŁĄCZNIE sceny z ≥1 performerem — wtedy sygnał jest dobry: 88-89% +nazwisk z próbki ma u nas performera z refem tpdb/stashdb. Zawężenie zostawia +~25-35% katalogu (~150-220k scen), czyli i tak dużo. + +Pozostałe pułapki, wszystkie obsłużone niżej: + - **tytuł bierzemy z `vit:"…"` (JS playera), NIE z `og:title`/`h1`** — te bywają + AI-owym spinem SEO ("Redhead MILF Fucks BBC in Courtroom Parody") zamiast realnego + tytułu sceny ("Alexis Fawx In Rise Anal In The Courtroom"). + - **obsadę czytamy TYLKO z `

Porn-stars: …

`** — na całej stronie + jest ~22 linków `videos.php?q=`, w sekcji obsady 1-2 realne (pułapka xxxfiles). + - sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE `

`, więc + parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie. + - listing wymaga bramki cookie (429 → challenge → retry), stąd własny `crawl_page` + zamiast domyślnego `browser_get` z bazy. + +Playback: extractor `fullvideosporn` (TXXX → get_file → 302 → znvcdn, portable cross-IP). +""" +from __future__ import annotations + +import html +import logging +import re +from datetime import UTC, datetime, timedelta + +from app.connectors.base import ( + RawPerformer, + RawPlaybackSource, + RawScene, + RawStudio, + RawTag, +) +from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper +from app.extractors.tubes.fullvideosporn import _new_session, gated_get +from app.normalize.text import slugify + +log = logging.getLogger(__name__) + +_BASE = "https://fullvideosporn.com" + +_SCENE_URL_RE = re.compile(r'href="(/en/video/(\d+)/[a-z0-9\-_]+/)"', re.IGNORECASE) +_VIT_RE = re.compile(r'vit:\s*"([^"]+)"') +_OGTITLE_RE = re.compile(r'property="og:title" content="([^"]+)"', re.IGNORECASE) +_DUR_RE = re.compile(r'og:video:duration" content="([0-9]+)"', re.IGNORECASE) +_THUMB_RE = re.compile(r'property="og:image" content="([^"]+)"', re.IGNORECASE) +# Każda sekcja to osobny

Label: ....

+_SECTION_RE = re.compile(r'

(.*?)

', re.IGNORECASE | re.DOTALL) +_ANCHOR_RE = re.compile(r">([^<>]{2,60})") +_SUBMITTED_RE = re.compile( + r"Submitted:\s*\s*(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago", + re.IGNORECASE, +) +_UNIT_DAYS = { + "second": 0, "minute": 0, "hour": 0, + "day": 1, "week": 7, "month": 30, "year": 365, +} + + +def _submitted_to_date(detail_html: str): + """`Submitted: 3 days ago` → data. Strona nie podaje daty wprost, a + to jest data uploadu na tube (nie premiery studia) — traktujemy jak inne tuby.""" + m = _SUBMITTED_RE.search(detail_html) + if not m: + return None + n, unit = int(m.group(1)), m.group(2).lower() + days = n * _UNIT_DAYS.get(unit, 0) + return (datetime.now(UTC) - timedelta(days=days)).date() + + +class FullVideosPornScraper(BaseBrowseScraper): + sitetag = "fullvideosporn" + + def _listing_url(self, page: int) -> str: + return f"{_BASE}/en/videos.php?p={page}&s=l" + + 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 not in seen: + seen.add(url) + out.append(url) + return out + + def crawl_page(self, page: int) -> list[RawScene] | None: + """Własna implementacja, bo listing i detale wymagają bramki cookie (429 → + challenge → retry) i wspólnej sesji, czego `browser_get` z bazy nie robi.""" + session = _new_session() + r = gated_get(session, self._listing_url(page), timeout=self._timeout) + if r is None or r.status_code != 200: + log.warning( + "fullvideosporn listing page=%d status=%s", page, getattr(r, "status_code", None) + ) + return None + urls = self._extract_scene_urls(r.text) + if not urls: + return [] + + out: list[RawScene] = [] + gated = 0 + for scene_url in urls: + d = gated_get(session, scene_url, timeout=self._timeout) + if d is None or d.status_code != 200: + continue + try: + raw = self._parse_detail(scene_url, d.text) + except Exception as e: + log.warning("fullvideosporn detail parse failed %s: %s", scene_url, e) + continue + if raw is None: + gated += 1 + continue + out.append(raw) + if gated: + log.info( + "fullvideosporn page=%d: %d/%d scen pominietych (brak obsady)", + page, gated, len(urls), + ) + return out + + def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: + # Sekcje metadanych — każda w swoim

. + performers: list[RawPerformer] = [] + tags: list[RawTag] = [] + studio: RawStudio | None = None + seen_p: set[str] = set() + seen_t: set[str] = set() + + for sec in _SECTION_RE.findall(detail_html): + label = re.sub(r"<[^>]+>", " ", sec).strip().lower() + names = [html.unescape(x).strip() for x in _ANCHOR_RE.findall(sec)] + if label.startswith("porn-stars"): + for name in names: + sl = slugify(name) + if sl and sl not in seen_p: + seen_p.add(sl) + performers.append( + RawPerformer(external_id=f"{self.sitetag}:performer:{sl}", name=name) + ) + elif label.startswith("porn site"): + if names and studio is None: + sname = names[0] + studio = RawStudio( + external_id=f"{self.sitetag}:studio:{slugify(sname)}", + name=sname, + slug=slugify(sname), + ) + elif label.startswith("porn categories"): + for name in names: + sl = slugify(name) + if sl and sl not in seen_t: + seen_t.add(sl) + tags.append( + RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl) + ) + + # BRAMKA: bez obsady nie ingestujemy (patrz docstring — 2/3 katalogu to + # spunowane tytuły bez performera, które weszłyby jako puste orphany). + if not performers: + return None + + # Tytuł z playera; og:title/h1 bywa AI-owym spinem SEO. + tm = _VIT_RE.search(detail_html) + title = html.unescape(tm.group(1)).strip() if tm else "" + if not title: + om = _OGTITLE_RE.search(detail_html) + title = html.unescape(om.group(1)).strip() if om else "" + if not title: + return None + + dm = _DUR_RE.search(detail_html) + duration_sec = int(dm.group(1)) if dm else None + thm = _THUMB_RE.search(detail_html) + thumbnail_url = thm.group(1) if thm else None + + # Tag == nazwisko performera (np. „Octavia Red" bywa i kategorią) → wytnij. + tags = [t for t in tags if t.slug not in seen_p] + + return RawScene( + external_id=f"{self.sitetag}:{scene_url}", + title=title, + release_date=_submitted_to_date(detail_html), + 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 2d4a625..9e7c805 100644 --- a/app/extractors/__init__.py +++ b/app/extractors/__init__.py @@ -30,6 +30,7 @@ from app.extractors.tubes import ( eporner, freshporno, fullmovies, + fullvideosporn, galaxyporn, hdporngg, hqfap, @@ -117,6 +118,10 @@ _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, + # 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. + "fullvideosporn": fullvideosporn.extract, "siskavideo": _embed_iframe.extract, "porn4dayspw": _embed_iframe.extract, "porndishcom": _embed_iframe.extract, diff --git a/app/extractors/tubes/_txxx.py b/app/extractors/tubes/_txxx.py new file mode 100644 index 0000000..3ddb042 --- /dev/null +++ b/app/extractors/tubes/_txxx.py @@ -0,0 +1,39 @@ +"""Wspólne elementy sieci TXXX (vjav, fullvideosporn/sextu, …). + +Silnik TXXX oddaje URL pliku przez `GET /api/videofile.php?video_id=&lifetime=N` +w polu `video_url`, zaciemnionym DWIEMA warstwami: + 1. wielkie/małe litery łacińskie podmienione na cyrylickie homoglify (М→M, С→C, …), + 2. custom alfabet base64: `,`→`/`, `~`→`=`, `-`→`+`. + +Po odkręceniu obu i b64decode dostajemy `/get_file/...` (czasem absolutny URL). +Wyniesione tutaj, żeby vjav i fullvideosporn nie trzymały dwóch kopii dekodera. +""" +from __future__ import annotations + +import base64 + +# Cyrylickie homoglify → łacina. Bez tego b64decode dostaje śmieci. +HOMOGLYPHS = str.maketrans( + { + "А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H", "К": "K", "М": "M", + "О": "O", "Р": "P", "Т": "T", "Х": "X", "У": "Y", + "а": "a", "с": "c", "е": "e", "о": "o", "р": "p", "х": "x", "у": "y", + } +) + + +def decode_video_url(obfuscated: str) -> str | None: + """Zaciemniony `video_url` → ścieżka/URL `get_file`. None gdy dekod padnie.""" + if not obfuscated: + return None + clean = ( + obfuscated.translate(HOMOGLYPHS) + .replace(",", "/") + .replace("~", "=") + .replace("-", "+") + ) + clean += "=" * (-len(clean) % 4) # padding do wielokrotności 4 + try: + return base64.b64decode(clean).decode("utf-8", "ignore") + except Exception: + return None diff --git a/app/extractors/tubes/fullvideosporn.py b/app/extractors/tubes/fullvideosporn.py new file mode 100644 index 0000000..5210739 --- /dev/null +++ b/app/extractors/tubes/fullvideosporn.py @@ -0,0 +1,144 @@ +"""fullvideosporn.com (= sextu.com pod nową marką) — TXXX network, direct mp4. + +To NIE jest klon fullmovies.xxx (inny silnik, inny katalog — 0/140 pokrycia tytułów). + +**Bramka cookie**: pierwszy request z nowej sesji dostaje HTTP 429 + 342-bajtowy JS +challenge ustawiający ciasteczko (`document.cookie = 'PxeA3f=; max-age=600'`). +Wystarczy wyregexować parę nazwa=wartość i powtórzyć request — bez proxy, bez +przeglądarki. Ciasteczko żyje 600 s, więc trzymamy je w sesji modułu. + +Stream (identyczny jak vjav, rodzina TXXX): + GET /api/videofile.php?video_id=&lifetime=8640000 + → [{"format":"_lq.mp4","video_url":"",...}] + dekod (`_txxx.decode_video_url`) → `/get_file/...` (relatywny, prepend host) + GET get_file bez follow → 302 → `https://sextuN.znvcdn.com/t=.../....mp4` + +`_lq.mp4` w nazwie myli — to realnie 1280x720 (~1,29 Mbps), jedyny dostępny format. + +**Cross-IP**: finalny URL CDN wybity z IP VPS gra z residential (206, `video/mp4`, +bez Referera) → token NIE jest IP-bound, `mobile_direct_ok`. ALE sam VPS dostaje od +CDN 429 (reputacja IP datacenter), więc **health-check playbacku z VPS będzie +fałszywie negatywny** — nie traktuj tego jako regresji. +""" +from __future__ import annotations + +import json +import logging +import re +from urllib.parse import urlparse + +from app.extractors._fetch import _DEFAULT_IMPERSONATE, _DEFAULT_UA, _HAS_CURL_CFFI +from app.extractors._models import StreamSource +from app.extractors.tubes import _txxx + +log = logging.getLogger(__name__) + +_BASE = "https://fullvideosporn.com" +_VIDEO_ID_RE = re.compile(r"/video/(\d+)/") +# `