"""latestpornvideo.com, latest-vids browse scraper. Historia: dawniej performer-driven search scraper (`/actor//`). 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//`, page 1 = `/`), 2026-06-22. Listing card (zero detail-fetchy, detail page nie ma performerów ani duration):
data-main-thumb="-YYYY-MM-DD-...-cover.jpg" → tytuł, miniatura, studio+release_date (z nazwy thumba albo z tytułu " 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 = //. """ 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']*\bclass="([^"]+)"', re.IGNORECASE) _LINK_RE = re.compile(r'-YYYY-MM-DD--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ł: ` YY MM DD ` (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/.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 (//), 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 (`-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: ` 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