"""siska.tv (do 2026-09-22 siska.video), latest-vids browse scraper. Historia: dawniej performer-driven search scraper (`?s=`), ale siska zepsuła wyszukiwarkę (zwraca latest niezależnie od query). Przerobione na BROWSE (latest chronologicznie z listingu, patrz `_listing_url`), re-enabled 2026-06-20 (user fa4083a2). Cały blok kafelka listingu ma komplet metadanych (zero detail-fetchy): 40 : 27 <Performer> - <Tytuł> - <Studio> → tytuł, duration, miniatura, performer+studio (alt), kategoria (ścieżka thumba). Playback: świeże filmy embedują playmogo (DoodStream clone) + luluvid (filemoon family). Extractor `siskavideo` → `_embed_iframe.extract` oddaje type='hoster'; telefon resolwuje phone-side. page_url = video.php?videoID=. """ from __future__ import annotations import logging import re from app.connectors.base import ( RawFingerprint, RawPerformer, RawPlaybackSource, RawScene, RawStudio, RawTag, ) from app.connectors.direct_scrapers._browse_base import ( BaseBrowseScraper, compute_thumbnail_phash, ) from app.extractors import browser_get log = logging.getLogger(__name__) # Domena siska.video wygasla 2026-09-22 (NXDOMAIN), ten sam serwis dziala dalej pod # siska.tv: te same numery videoID i sciezki miniatur. Adresy w bazie przepisane na # siska.tv tego samego dnia. Kafelki przyjmujemy z obu domen i z www, ale zapisujemy # zawsze jeden kanoniczny adres (_canonical), bo external_id sceny i unikalny klucz # zrodla to sam adres; www.siska.video i siska.video dawaly 103 zdublowane zrodla. _BASE = "https://siska.tv" _HOST_RE = re.compile(r"^https://(?:www\.)?siska\.(?:tv|video)/", re.IGNORECASE) # Kafelek: . Reszta pól w oknie po tym matchu. # Strona nie escapuje apostrofu w atrybutach (title='Brenna's Mouths'), wiec wartosc # konczy sie na apostrofie, po ktorym idzie nastepny atrybut, a nie na pierwszym. _A_RE = re.compile( r"[^<>]*?)'\s+" r"href='(?Phttps://(?:www\.)?siska\.(?:tv|video)/video\.php\?videoID=\d+)'", re.IGNORECASE, ) _DUR_RE = re.compile(r"th_video_duration'>\s*([\d]{1,2}(?:\s*:\s*[\d]{2}){1,2})\s*<") _THUMB_RE = re.compile(r"data-src='([^']+\.(?:jpg|jpeg|webp|png))'", re.IGNORECASE) _ALT_RE = re.compile(r"alt='([^<>]*?)'(?=\s+[\w-]+=|\s*/?>)") _CAT_RE = re.compile(r"/category/([^/]+)/", re.IGNORECASE) def _canonical(url: str) -> str: return _HOST_RE.sub(_BASE + "/", url) def _parse_duration(text: str | None) -> int | None: """`40 : 27` → 2427 (MM:SS); `1 : 05 : 30` → H:MM:SS. None gdy brak.""" if not text: return None parts = [p.strip() for p in text.split(":")] try: nums = [int(p) for p in parts] except ValueError: return None if len(nums) == 2: return nums[0] * 60 + nums[1] if len(nums) == 3: return nums[0] * 3600 + nums[1] * 60 + nums[2] return None def _slugify(name: str) -> str: return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") class SiskaScraper(BaseBrowseScraper): sitetag = "siskavideo" def _listing_url(self, page: int) -> str: # Na siska.tv `/page//` oddaje zawsze strone 1. Najnowsze po kolei (18 na # strone, videoID malejaco) sa pod `best_xvideos.php`, mimo nazwy. return f"{_BASE}/best_xvideos.php?page={page}" # crawl_page nadpisany → poniższe abstrakcje nieużywane, ale wymagane do instancji. def _extract_scene_urls(self, listing_html: str) -> list[str]: return [_canonical(m.group("url")) for m in _A_RE.finditer(listing_html)] def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: return None def crawl_page(self, page: int) -> list[RawScene] | None: url = self._listing_url(page) try: res = browser_get(url, timeout=self._timeout) html = res.text if hasattr(res, "text") else res except Exception as e: log.warning("siska browse listing fetch failed (page %d): %s", page, e) return None out: list[RawScene] = [] seen: set[str] = set() for m in _A_RE.finditer(html): scene_url = _canonical(m.group("url")) if scene_url in seen: continue seen.add(scene_url) title = (m.group("title") or "").strip() if not title: continue window = html[m.end():m.end() + 700] dm = _DUR_RE.search(window) duration_sec = _parse_duration(dm.group(1) if dm else None) tm = _THUMB_RE.search(window) thumbnail_url = _canonical(tm.group(1)) if tm else None # alt='Performer - Tytuł - Studio' → performer (pierwszy), studio (ostatni). # Kilka osob stoi w pierwszym czlonie po przecinku ("A, B - Tytul"). performers: list[RawPerformer] = [] studio: RawStudio | None = None am = _ALT_RE.search(window) if am and " - " in am.group(1): parts = [p.strip() for p in am.group(1).split(" - ") if p.strip()] if len(parts) >= 2: sname = parts[-1] for pname in (n.strip() for n in parts[0].split(",")): if pname: performers.append( RawPerformer( external_id=f"{self.sitetag}:performer:{_slugify(pname)}", name=pname, ) ) if sname and len(parts) >= 3: studio = RawStudio( external_id=f"{self.sitetag}:studio:{_slugify(sname)}", name=sname, slug=_slugify(sname), ) # Kategoria ze ścieżki miniatury (/category//.jpg). tags: list[RawTag] = [] if thumbnail_url: cm = _CAT_RE.search(thumbnail_url) if cm and cm.group(1).lower() not in ("uncategorized", ""): cat = cm.group(1).replace("-", " ").replace("_", " ").strip() tags.append( RawTag( external_id=f"{self.sitetag}:tag:{_slugify(cat)}", name=cat, slug=_slugify(cat), ) ) fingerprints: list[RawFingerprint] = [] if thumbnail_url: ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/") if ph: fingerprints.append(RawFingerprint(kind="phash", value=ph)) playback_sources = [ RawPlaybackSource( origin=f"tube:{self.sitetag}", page_url=scene_url, duration_sec=duration_sec, thumbnail_url=thumbnail_url, ) ] out.append( RawScene( external_id=f"{self.sitetag}:{scene_url}", title=title, duration_sec=duration_sec, url=scene_url, studio=studio, performers=performers, tags=tags, fingerprints=fingerprints, playback_sources=playback_sources, ) ) log.info("siska browse page %d: %d scenes", page, len(out)) return out