goon/app/connectors/direct_scrapers/latestpornvideo.py
goon-foss 97bca096e6
Some checks are pending
Backend tests / test (push) Waiting to run
Build Android APK / build (push) Waiting to run
feat: alarm na serwis bez udanych odtworzen, miniatury latestpornvideo z plakatu lulu
Watchdog odtwarzania: origin z >= 300 zywymi zrodlami i zerem udanych
odtworzen w 7 dni (hentaimama i xanime padly tak, ze nic nie alarmowalo).
latestpornvideo: link podgladu pixhost zamieniany na obrazek, usuniety
obrazek i brak miniatury -> plakat odtwarzacza lulu; skrypt dla starych.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:41:11 +02:00

237 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""latestpornvideo.com, latest-vids browse scraper.
Historia: dawniej performer-driven search scraper (`/actor/<slug>/`). Problem
(user-report 1da0375e): search-scraper ingestuje TYLKO sceny performerów, których
akurat szukamy → feed strony "latest" nigdy nie wpada, w apce widać stary zestaw,
a na stronie jest świeży. Przerobione na BROWSE (latest chronologicznie z
`/page/<n>/`, page 1 = `/`), 2026-06-22.
Listing card (zero detail-fetchy, detail page nie ma performerów ani duration):
<article class="... post-<id> ... category-<cat> tag-<x> tag-<y> ...">
<a href="https://latestpornvideo.com/<id>/" title="<Tytuł>">
data-main-thumb="<Studio>-YYYY-MM-DD-...-cover.jpg"
→ tytuł, miniatura, studio+release_date (z nazwy thumba albo z tytułu
"<Studio> YY MM DD ..."). Performerzy: listing ICH NIE MA czysto
(homepage karty bez `actors-*`, jak na stronach /actor/), a `tag-*` miesza
fragmenty imion z gatunkami → NIE ufamy tagom jako performerom; performera
dorabia canonical-merge po tytule+duration. Tagi bierzemy ostrożnie.
Playback: luluvid (filemoon family) iframe → extractor `latestpornvideocom`
(_embed_iframe → type='hoster'), telefon resolwuje phone-side. page_url = /<id>/.
"""
from __future__ import annotations
import html
import logging
import re
from datetime import date
import httpx
from app.connectors.base import (
RawPlaybackSource,
RawScene,
RawStudio,
RawTag,
)
from app.connectors.direct_scrapers._browse_base import (
BaseBrowseScraper,
compute_thumbnail_phash,
)
from app.extractors import browser_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://latestpornvideo.com"
_ARTICLE_RE = re.compile(r'<article[^>]*\bclass="([^"]+)"', re.IGNORECASE)
_LINK_RE = re.compile(r'<a\s+href="([^"]+)"\s+title="([^"]+)"', re.IGNORECASE)
_THUMB_RE = re.compile(r'data-main-thumb="([^"]+)"', re.IGNORECASE)
_TICDN_RE = re.compile(r"^https?://(?:[a-z0-9-]+\.)*ticdn\.net/snaps/", re.IGNORECASE)
_CLASS_TAG_RE = re.compile(r"\btag-([a-z0-9-]+)")
_CLASS_CAT_RE = re.compile(r"\bcategory-([a-z0-9-]+)")
# Nazwa thumba: `<Studio>-YYYY-MM-DD-<rest>-cover.jpg`.
_THUMB_NAME_RE = re.compile(r"/([A-Za-z0-9][A-Za-z0-9-]*?)-(\d{4})-(\d{2})-(\d{2})-", re.IGNORECASE)
# Tytuł: `<Studio> YY MM DD <rest>` (np. "MySexMobile 20 10 23 Abella Danger").
_TITLE_DATE_RE = re.compile(r"^(.*?)\s*\b(\d{2})\s+(\d{2})\s+(\d{2})\b")
# Karty homepage zawsze siedzą w kategorii "latest-porn-videos", to nie jest tag.
# "mixed-porn" i "porn-movie": od 09.2026 serwis daje KAZDEMU wpisowi tylko te kategorie,
# wiec nic nie mowia (zgloszenie 3204ee85, 2026-10-12: "tylko mixed porn").
_CAT_SKIP = {"latest-porn-videos", "uncategorized", "", "mixed-porn", "porn-movie"}
_PIXHOST_SHOW_RE = re.compile(r"^https?://(?:www\.)?pixhost\.to/show/\d+/[^/?#]+$", re.IGNORECASE)
_OG_IMAGE_RE = re.compile(r"""property=["']og:image["'][^>]*content=["']([^"']+)""", re.IGNORECASE)
_LULU_EMBED_RE = re.compile(r"https?://(?:www\.)?lulu[a-z0-9]*\.[a-z]+/(?:e|d|v)/([a-z0-9]+)", re.IGNORECASE)
def _pixhost_removed(url: str) -> bool:
"""pixhost przekierowuje usuniety obrazek na baner /image/removed.png."""
try:
r = httpx.head(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10.0, follow_redirects=False)
except Exception:
return False
return r.status_code in (301, 302, 303, 307, 308) and "removed" in (r.headers.get("location") or "")
def _lulu_poster(page_url: str, timeout: float) -> str | None:
"""Plakat z odtwarzacza lulu osadzonego we wpisie (img.lulucdn.com/<id>.jpg)."""
try:
page = browser_get(page_url, timeout=timeout).text
except Exception:
return None
m = _LULU_EMBED_RE.search(page)
return f"https://img.lulucdn.com/{m.group(1)}.jpg" if m else None
def better_thumb(thumb: str | None, page_url: str, *, timeout: float = 30.0) -> str | None:
"""Miniatura, ktora da sie pokazac (zgloszenia eb20f1c0/4d8d5daf, 2026-10-12).
- `pixhost.to/show/...` to strona podgladu (HTML), nie obrazek: bierzemy jej og:image.
- Usuniety obrazek pixhost i brak miniatury: plakat z odtwarzacza lulu z wpisu.
Dodatkowe zapytania tylko w tych przypadkach (ok. 10% wpisow).
"""
if thumb and _PIXHOST_SHOW_RE.match(thumb):
try:
show = httpx.get(thumb, headers={"User-Agent": "Mozilla/5.0"}, timeout=10.0).text
m = _OG_IMAGE_RE.search(show)
thumb = html.unescape(m.group(1)) if m else None
except Exception:
thumb = None
if thumb and "pixhost." in thumb and _pixhost_removed(thumb):
thumb = None
if not thumb:
return _lulu_poster(page_url, timeout)
return thumb
def _name_from_slug(slug: str) -> str:
return " ".join(w.capitalize() for w in slug.split("-") if w)
class LatestPornVideoScraper(BaseBrowseScraper):
sitetag = "latestpornvideocom"
def _listing_url(self, page: int) -> str:
return _BASE + "/" if page <= 1 else f"{_BASE}/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 [m.group(1) for m in _LINK_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)
text = res.text if hasattr(res, "text") else res
except Exception as e:
log.warning("latestpornvideo browse listing fetch failed (page %d): %s", page, e)
return None
anchors = list(_ARTICLE_RE.finditer(text))
out: list[RawScene] = []
seen: set[str] = set()
for idx, m in enumerate(anchors):
cls = m.group(1)
win_end = anchors[idx + 1].start() if idx + 1 < len(anchors) else m.end() + 1500
window = text[m.start():win_end]
link_m = _LINK_RE.search(window)
if not link_m:
continue
scene_url = link_m.group(1).rstrip("/") + "/"
# tylko właściwe posty scen (/<digits>/), bez nav/kategorii
if not re.fullmatch(rf"{re.escape(_BASE)}/\d+/", scene_url) or scene_url in seen:
continue
seen.add(scene_url)
title = html.unescape(link_m.group(2)).strip()
if not title:
continue
thumb_m = _THUMB_RE.search(window)
thumb = thumb_m.group(1) if thumb_m else None
if thumb:
# ticdn.net (stary serwer miniatur DoodStream) nie istnieje, te same pliki
# leza na imgds.net (2026-10-15, 8 tys. martwych kafelkow).
thumb = _TICDN_RE.sub("https://imgds.net/snaps/", thumb)
# Studio + release_date z nazwy thumba (`<Studio>-YYYY-MM-DD-`).
studio: RawStudio | None = None
release_date: date | None = None
if thumb and (tn := _THUMB_NAME_RE.search(thumb)):
studio_raw = tn.group(1).replace("-", " ").strip()
if studio_raw:
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(studio_raw)}",
name=studio_raw, slug=slugify(studio_raw),
)
try:
release_date = date(int(tn.group(2)), int(tn.group(3)), int(tn.group(4)))
except ValueError:
release_date = None
# Fallback z tytułu: `<Studio> YY MM DD ...`.
if studio is None or release_date is None:
if tm2 := _TITLE_DATE_RE.search(title):
if release_date is None:
try:
release_date = date(
2000 + int(tm2.group(2)), int(tm2.group(3)), int(tm2.group(4))
)
except ValueError:
release_date = None
studio_raw = tm2.group(1).strip(" -–")
if studio is None and 2 <= len(studio_raw) <= 30:
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(studio_raw)}",
name=studio_raw, slug=slugify(studio_raw),
)
# Tagi: tylko prawdziwe kategorie (category-*), bez "latest-porn-videos".
# `tag-*` POMIJAMY, to mieszanka fragmentów imion performerów i gatunków,
# bez `actors-*` (jak na /actor/) nie da się ich rozdzielić → byłby szum.
tags: list[RawTag] = []
seen_tag: set[str] = set()
for cm in _CLASS_CAT_RE.finditer(cls):
sl = cm.group(1)
if sl in _CAT_SKIP or sl in seen_tag:
continue
seen_tag.add(sl)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=_name_from_slug(sl), slug=sl))
# Po odczycie studia i daty z nazwy pliku, bo zapas (plakat lulu) tej nazwy nie ma.
thumb = better_thumb(thumb, scene_url, timeout=self._timeout)
fingerprints = []
if thumb:
ph = compute_thumbnail_phash(thumb, referer=_BASE + "/")
if ph:
from app.connectors.base import RawFingerprint
fingerprints.append(RawFingerprint(kind="phash", value=ph))
out.append(
RawScene(
external_id=f"{self.sitetag}:{scene_url}",
title=title,
release_date=release_date,
url=scene_url,
studio=studio,
performers=[],
tags=tags,
fingerprints=fingerprints,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
thumbnail_url=thumb,
)
],
)
)
log.info("latestpornvideo browse page %d: %d scenes", page, len(out))
return out