"""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, ) ], )