diff --git a/app/connectors/direct_scrapers/__init__.py b/app/connectors/direct_scrapers/__init__.py index c947afe..0a9b146 100644 --- a/app/connectors/direct_scrapers/__init__.py +++ b/app/connectors/direct_scrapers/__init__.py @@ -49,6 +49,7 @@ from app.connectors.direct_scrapers.pornbusy import PornBusyScraper from app.connectors.direct_scrapers.pornmike import PornMikeScraper from app.connectors.direct_scrapers.kporner8 import KPorner8Scraper from app.connectors.direct_scrapers.hentaimama import HentaiMamaScraper +from app.connectors.direct_scrapers.xanimeporn import XAnimePornScraper from app.connectors.direct_scrapers.fullporner import FullPornerScraper from app.connectors.direct_scrapers.watchporn import WatchPornScraper from app.connectors.direct_scrapers.youperv import YoupervScraper @@ -199,6 +200,13 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [ # Motyw z rodziny Dooplay, ale DooplayConnector NIE pasuje (brak data-fl-source). # Playback: extractor `hentaimama`, POST admin-ajax za Cloudflare → Bright Data. HentaiMamaScraper, + # XAnimePornScraper — dodany 2026-08-04, drugie źródło wertykału hentai. + # NIE re-host hentaimamy: wspólny odcinek (Shoujo Ramune 7) leży na obu, ale to inne + # pliki — tu jest jawny wybór 1080p (222 MB) / 720p (84 MB), tam jedna jakość bez + # etykiety. Paginacja strony głównej ZEPSUTA (str. 2-3 = str. 1, jak przy 8kporner), + # więc listing z dwóch archiwów: /uncensored/ i /censored/, które stronicują poprawnie. + # Playback bez Cloudflare i bez proxy, w odróżnieniu od hentaimamy. + XAnimePornScraper, # FullPornerScraper, dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej # 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured". # Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania. diff --git a/app/connectors/direct_scrapers/xanimeporn.py b/app/connectors/direct_scrapers/xanimeporn.py new file mode 100644 index 0000000..f71f976 --- /dev/null +++ b/app/connectors/direct_scrapers/xanimeporn.py @@ -0,0 +1,172 @@ +"""xanimeporn.com — browse scraper. Drugie źródło wertykału hentai. Dodany 2026-08-04. + +**NIE jest re-hostem hentaimamy — sprawdzone na wspólnym odcinku.** Shoujo Ramune 7 +leży na obu stronach, ale to zupełnie inne pliki: hentaimama serwuje +`gdvid.info/S/shoujo-ramune-7.mp4` (jedna jakość, bez etykiety), a tutaj jest +`download.xanimeporn.com/videos/1080p/[1080p]69951.mp4` z **jawnym wyborem 1080p (222 MB) +i 720p (84 MB)**. To realna przewaga jakościowa, nie duplikat. + +Kryterium jak przy [[hentaimama]]: dla animacji nie istnieje kanon, więc każda pozycja +jest orphanem i to jest poprawne. Nie ma obsady, nie ma canonical matcha — jakość mierzy +komplet seria + odcinek + jakość pliku. + +**Listing idzie z RSS-a i to jedyna droga, która działa.** Sprawdzone trzy inne i każda +ma inną wadę: + + - `/page/2/`, `/page/3/` — oddają ten sam zestaw co strona 1 (28 z 32 wspólnych), + czyli paginacja jest ZEPSUTA, identycznie jak przy 8kporner + - `/uncensored/` i `/censored/` — stronicują poprawnie, ale NIE są chronologiczne: + pierwsze pozycje są z 2024, a zakres sięga 2015-2026. Browse-latest nigdy nie + zobaczyłby nowego odcinka, czyli dokładnie pułapka z freshporno + - `/hentai-list/` — cały katalog (1048 pozycji) na jednej stronie, dobre do + przyszłego deep-crawlu, bezużyteczne do „najnowszych" + +`/feed/` i `/feed/?paged=N` są chronologiczne (strona 1 = dziś, strona 2 = miesiąc +wstecz) i dają po 10 pozycji, co przy kilku odcinkach dziennie w zupełności wystarcza. + +**Drugi filtr, też wymuszony przez dane:** slugi gatunków (`/futanari/`, `/big-tits/`, +`/betrayal/`) są takimi samymi slugami najwyższego poziomu jak odcinki i przechodziły +przez listę nazw nawigacji. Dlatego `_parse_detail` wymaga `postid-` w klasie +`` — mają go tylko pojedyncze wpisy, nie archiwa. Ten sam identyfikator jest +zresztą potrzebny extractorowi, więc brak = pozycja i tak bezużyteczna. + +Playback: `videos.xanimeporn.com/download.php?id=&quality=…` (extractor +`xanimeporn`). Bez Cloudflare i bez proxy, w odróżnieniu od hentaimamy. +""" +from __future__ import annotations + +import html as html_mod +import logging +import re +from datetime import datetime + +from app.connectors.base import RawPlaybackSource, RawScene, RawTag +from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper +from app.extractors import browser_get +from app.normalize.text import slugify + +log = logging.getLogger(__name__) + +_BASE = "https://xanimeporn.com" + +_FEED_LINK_RE = re.compile(r"(https://xanimeporn\.com/[a-z0-9][a-z0-9-]+/)") +#: Tylko pojedyncze wpisy mają `postid-` w klasie ; archiwa gatunków nie. +_POST_ID_RE = re.compile(r"\bpostid-(\d+)\b") +#: Kategoria z RSS-a — na stronie odcinka oba linki są w menu, więc czytanie ich +#: stamtąd dałoby ZAWSZE „censored" i „uncensored" naraz. +_FEED_ITEM_RE = re.compile(r"(.*?)", re.DOTALL) +_FEED_CATEGORY_RE = re.compile(r"") +_OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,200})") +_PUBLISHED_RE = re.compile( + r"property=['\"]article:published_time['\"][^>]*content=['\"]([^'\"]+)" +) +_POSTER_RE = re.compile(r"posterImage:\s*'([^']+)'") +#: „Shoujo Ramune Episode 7 [Sub-ENG] | X Anime Porn" → tytuł bez sufiksu serwisu. +_TITLE_SUFFIX_RE = re.compile(r"\s*\|\s*X Anime Porn\s*$", re.IGNORECASE) +_SERIES_RE = re.compile(r"^(.*?)\s+Episode\s+\d+", re.IGNORECASE) + + +class XAnimePornScraper(BaseBrowseScraper): + sitetag = "xanimeporn" + + def _listing_url(self, page: int) -> str: + return f"{_BASE}/feed/" if page <= 1 else f"{_BASE}/feed/?paged={page}" + + def _extract_scene_urls(self, listing_html: str) -> list[str]: + return list(dict.fromkeys(_FEED_LINK_RE.findall(listing_html))) + + def crawl_page(self, page: int) -> list[RawScene] | None: + """Jak w bazie, ale kategorię (censored/uncensored) bierzemy z RSS-a, bo na + stronie odcinka oba linki siedzą w menu nawigacyjnym.""" + try: + res = browser_get(self._listing_url(page), timeout=self._timeout) + res.raise_for_status() + except Exception as e: + log.warning("%s: feed strona %d fail: %s", self.sitetag, page, e) + return None + + cats: dict[str, str] = {} + for item in _FEED_ITEM_RE.findall(res.text): + lm = _FEED_LINK_RE.search(item) + if not lm: + continue + for cat in _FEED_CATEGORY_RE.findall(item): + if cat.strip().lower() in ("censored", "uncensored"): + cats[lm.group(1)] = cat.strip().lower() + break + + urls = self._extract_scene_urls(res.text) + if not urls: + return [] + out: list[RawScene] = [] + for url in urls: + try: + det = browser_get(url, timeout=self._timeout) + det.raise_for_status() + raw = self._parse_detail(url, det.text, cats.get(url, "")) + except Exception as e: + log.info("%s detail fail %s: %s", self.sitetag, url, e) + continue + if raw is not None: + out.append(raw) + return out + + def _parse_detail( # type: ignore[override] + self, scene_url: str, detail_html: str, archive: str = "" + ) -> RawScene | None: + # Archiwa gatunków (`/futanari/`, `/big-tits/`) mają takie same slugi jak odcinki + # i wchodziły jako pozycje bez daty i miniaturki. Tylko pojedynczy wpis ma postid. + if not _POST_ID_RE.search(detail_html): + return None + tm = _OG_TITLE_RE.search(detail_html) + if not tm: + return None + title = _TITLE_SUFFIX_RE.sub("", html_mod.unescape(tm.group(1))).strip() + if not title: + return None + + release_date = None + pm = _PUBLISHED_RE.search(detail_html) + if pm: + try: + release_date = datetime.fromisoformat(pm.group(1)).date() + except ValueError: + pass + + tags: list[RawTag] = [] + if archive: + tags.append( + RawTag( + external_id=f"{self.sitetag}:tag:{archive}", + name=archive.capitalize(), + slug=archive, + ) + ) + # Seria jako tag — pozwala grupować odcinki, tak samo jak w hentaimamie. + sm = _SERIES_RE.match(title) + if sm and slugify(sm.group(1)): + series = sm.group(1).strip() + tags.append( + RawTag( + external_id=f"{self.sitetag}:series:{slugify(series)}", + name=series, + slug=slugify(series), + ) + ) + + om = _POSTER_RE.search(detail_html) + slug = scene_url.rstrip("/").rsplit("/", 1)[-1] + return RawScene( + external_id=f"{self.sitetag}:{slug}", + title=title, + release_date=release_date, + url=scene_url, + tags=tags, + playback_sources=[ + RawPlaybackSource( + origin=f"tube:{self.sitetag}", + page_url=scene_url, + thumbnail_url=om.group(1).strip() if om else None, + ) + ], + ) diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py index 82bda4c..adc1bef 100644 --- a/app/extractors/__init__.py +++ b/app/extractors/__init__.py @@ -38,6 +38,7 @@ from app.extractors.tubes import ( hqporner, fullporner, hentaimama, + xanimeporn, kporner8, sexu, javflix, @@ -135,6 +136,10 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = { # wymaga Bright Data) → strona playera → bezpośredni mp4. Preferujemy gdvid, # bo oddaje plik bez tokenu, więc URL nie wygasa. Portable, mobile_direct_ok. "hentaimama": hentaimama.extract, + # xanimeporn — Fluid Player z PUSTYM , wideo wstrzykiwane w runtime. + # Wyjście: zakładka Downloads, czyli videos.xanimeporn.com/download.php?id=. + # Oddajemy download.php, nie docelowy plik, żeby token generował się przy odtwarzaniu. + "xanimeporn": xanimeporn.extract, # fullporner, iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości. # Zero tokenu i zero wygasania, URL składamy sami. Blokada jest na nagłówku (bez # UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi. diff --git a/app/extractors/tubes/xanimeporn.py b/app/extractors/tubes/xanimeporn.py new file mode 100644 index 0000000..4a2f970 --- /dev/null +++ b/app/extractors/tubes/xanimeporn.py @@ -0,0 +1,60 @@ +"""xanimeporn.com — mp4 z endpointu pobierania. Dodany 2026-08-04. + +Player to Fluid Player z **pustym ``** — źródło wstrzykiwane jest w +runtime, a skrypt inicjalizujący obsługuje wyłącznie fullscreen i analitykę. W statycznym +HTML nie ma ani jednego linku do wideo, więc wygląda to na ślepy zaułek. + +Wyjściem jest zakładka „Downloads", która niesie: + + https://videos.xanimeporn.com/download.php?id=&quality=1080p + https://videos.xanimeporn.com/download.php?id=&quality=720p + +`postId` siedzi w klasie `` (`postid-69951`), więc nie trzeba parsować zakładki — +składamy URL sami dla obu jakości. + +**Oddajemy `download.php`, a NIE docelowego pliku.** Endpoint przekierowuje na +`download.xanimeporn.com/...?s=&e=`, czyli URL z tokenem i wygasaniem. +Zwracając `download.php` zostawiamy generowanie tokenu na moment odtwarzania: telefon +idzie za przekierowaniem i dostaje świeży podpis, więc nic nie wietrzeje. To ta sama +lekcja co przy watchporn, gdzie zapisany statyczny URL po czasie padał. + +Zweryfikowane z VPS-a: 1080p 222 MB i 720p 84 MB, oba 206 na Range z `ftypisom`/`ftypmp42` +w pierwszym KB (faststart). Wymaga Referera i UA, oba dokłada `playback.py`. Bez +Cloudflare i bez proxy, w odróżnieniu od hentaimamy. +""" +from __future__ import annotations + +import logging +import re + +from app.extractors._fetch import fetch_tube_html +from app.extractors._models import StreamSource + +log = logging.getLogger(__name__) + +_BASE = "https://xanimeporn.com" +_POST_ID_RE = re.compile(r"\bpostid-(\d+)\b") +#: Serwis publikuje dokładnie te dwie jakości; kolejność = preferencja. +_QUALITIES = ("1080p", "720p") + + +def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: + html_text = fetch_tube_html(page_url, timeout=timeout) + m = _POST_ID_RE.search(html_text) + if not m: + log.info("xanimeporn: brak postid w klasie body na %s", page_url) + return None + post_id = m.group(1) + + return [ + StreamSource( + link=f"https://videos.xanimeporn.com/download.php?id={post_id}&quality={q}", + type="mp4", + quality=q, + referer=_BASE + "/", + # Zweryfikowane 206 + faststart z VPS-a; telefon sam podąża za 302 i + # dostaje własny, świeży token. + raw={"mobile_direct_ok": True}, + ) + for q in _QUALITIES + ]