goon/app/connectors/direct_scrapers/siska.py
goon-foss 494b15aedd
Some checks are pending
Backend tests / test (push) Waiting to run
fix(siska): kafelki z apostrofem w tytule i kilka osob w jednym nazwisku
Strona nie escapuje apostrofu w title/alt, wiec "Brenna's Mouths" urywal
dopasowanie i kafelek wypadal (3 z 18 na stronie). Pierwszy czlon alt z
kilkoma osobami ("A, B - Tytul") szedl jako jeden wykonawca.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:14:47 +02:00

194 lines
7.7 KiB
Python

"""siska.tv (do 2026-09-22 siska.video), latest-vids browse scraper.
Historia: dawniej performer-driven search scraper (`?s=<q>`), 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):
<a title='<Tytuł>' href='https://siska.video/video.php?videoID=<n>'>
<span class='th_video_duration'>40 : 27</span>
<img data-src='https://siska.video/category/<Kat>/<id>.jpg'
alt='<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=<n>.
"""
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: <a title='..' href='..videoID=N'>. 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"<a\s+title='(?P<title>[^<>]*?)'\s+"
r"href='(?P<url>https://(?: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/<n>/` 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/<Kat>/<id>.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