Strona filmu hqporner nie ma wlasnej okladki, a pierwszy _main.jpg na niej to kafelek polecanego filmu. Scraper go bral, wiec ~90% zrodel dostalo cudze miniatury (62 tys. zrodel, 5,4 tys. roznych miniatur). Okladka jest tylko w kafelku listingu (cover_<id>): browse zbiera je z listingu, wyszukiwarka z wynikow wyszukiwania. scripts/hqporner_fix_thumbs.py poprawia zapisane miniatury przechodzac listing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
6.8 KiB
Python
172 lines
6.8 KiB
Python
"""HQPornerBrowseScraper, latest-vids browse dla hqporner.com.
|
|
|
|
Dlaczego: hqporner był TYLKO w `ALL_DIRECT_SCRAPERS` (search po nazwisku performera),
|
|
więc nowe sceny wchodziły wyłącznie wtedy, gdy worker akurat szukał pasującego
|
|
performera. Efekt: 0 świeżych scen w 7 dni (audit 2026-07-26, user: "nie widzę nowych
|
|
scen z hqporner"), mimo że tube publikuje codziennie. Browse dokłada ścieżkę
|
|
niezależną od stanu performerów.
|
|
|
|
Listing: `https://hqporner.com/` (page 1 = newest), kolejne `?p=N`, ~46-50 scen/stronę.
|
|
Detail (`/hdporn/<id>-<slug>.html`) ma komplet metadanych:
|
|
- tytuł → `<title>` (Title Case; `<h1>` jest lowercase, więc gorszy),
|
|
- długość → meta description "Video duration is 46min 58sec",
|
|
- performerki → `<a href="/actress/<slug>">Display Name</a>` (display, nie slug),
|
|
- tagi → `/category/<slug>`,
|
|
- miniaturki NIE ma: okladka jest tylko w kafelku listingu (patrz _COVER_RE).
|
|
|
|
`external_id` MUSI być identyczny jak w `HQPornerScraper` (search): `hqpornercom:<url>`.
|
|
Inaczej ta sama scena z obu ścieżek zrobiłaby się dwoma wpisami.
|
|
|
|
Miniaturki fastporndelivery wymagają Referera (bez niego 403), backend i tak owija je
|
|
w `/proxy/img/`, a phash liczymy z jawnym refererem.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import html as _html
|
|
import logging
|
|
import re
|
|
|
|
from app.connectors.base import (
|
|
RawFingerprint,
|
|
RawPerformer,
|
|
RawPlaybackSource,
|
|
RawScene,
|
|
RawTag,
|
|
)
|
|
from app.connectors.direct_scrapers._browse_base import (
|
|
BaseBrowseScraper,
|
|
compute_thumbnail_phash,
|
|
)
|
|
from app.normalize.text import slugify
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_BASE = "https://hqporner.com"
|
|
|
|
_SCENE_HREF_RE = re.compile(r'/hdporn/(\d+)-([^"\.]+)\.html')
|
|
_TITLE_RE = re.compile(r"<title>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
|
# meta description: "... Video duration is 46min 58sec. Tags related ..."
|
|
_DUR_RE = re.compile(r"duration is\s*(?:(\d+)\s*min)?\s*(?:(\d+)\s*sec)?", re.IGNORECASE)
|
|
_ACTRESS_RE = re.compile(
|
|
r'<a[^>]+href=[\'"]/actress/([a-z0-9\-_]+)[\'"][^>]*>(.*?)</a>',
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
_CATEGORY_RE = re.compile(r'href=[\'"]/category/([a-z0-9\-_]+)[\'"]', re.IGNORECASE)
|
|
# Okladka filmu jest TYLKO w kafelku listingu (`<img id="cover_<id>" src=...>`). Strona
|
|
# filmu wlasnej okladki nie ma, a pierwszy `_main.jpg` na niej to kafelek innego,
|
|
# polecanego filmu. Braniem go ~90% zrodel hqporner dostalo cudze miniatury (62 tys.
|
|
# zrodel, 5,4 tys. roznych miniatur; zgloszenie 0982b000, 2026-09-24).
|
|
_COVER_RE = re.compile(
|
|
r'id=["\']cover_(\d+)["\']\s+src=["\'](//fastporndelivery\.hqporner\.com/imgs/[^"\']+_main\.jpg)',
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def listing_covers(listing_html: str) -> dict[str, str]:
|
|
"""Okladki z kafelkow listingu (glowny, wyszukiwarka, kategorie): {id filmu: url}."""
|
|
return {vid: "https:" + src for vid, src in _COVER_RE.findall(listing_html)}
|
|
|
|
|
|
_TAG_STRIP = re.compile(r"<[^>]+>")
|
|
|
|
# hqporner wrzuca do /category/ także jakość ("1080p-porn", "4k-porn"). To nie jest tag
|
|
# treści, tylko atrybut pliku, i zaśmieca chmurę tagów (user: "śmieciowe tagi").
|
|
_QUALITY_TAG_RE = re.compile(r"^(?:\d{3,4}p|4k|8k|hd|uhd|full-?hd)(?:-porn)?$", re.IGNORECASE)
|
|
|
|
|
|
def _clean(text: str) -> str:
|
|
return _html.unescape(_TAG_STRIP.sub("", text)).strip()
|
|
|
|
|
|
class HQPornerBrowseScraper(BaseBrowseScraper):
|
|
sitetag = "hqpornercom"
|
|
|
|
def _listing_url(self, page: int) -> str:
|
|
return _BASE + "/" if page <= 1 else f"{_BASE}/?p={page}"
|
|
|
|
def _extract_scene_urls(self, listing_html: str) -> list[str]:
|
|
# Okladki zapamietujemy tu, bo _parse_detail dostaje juz tylko strone filmu.
|
|
if not hasattr(self, "_covers"):
|
|
self._covers: dict[str, str] = {}
|
|
self._covers.update(listing_covers(listing_html))
|
|
seen: set[str] = set()
|
|
out: list[str] = []
|
|
for m in _SCENE_HREF_RE.finditer(listing_html):
|
|
url = f"{_BASE}/hdporn/{m.group(1)}-{m.group(2)}.html"
|
|
if url in seen:
|
|
continue
|
|
seen.add(url)
|
|
out.append(url)
|
|
return out
|
|
|
|
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
|
tm = _TITLE_RE.search(detail_html)
|
|
title = _clean(tm.group(1)) if tm else ""
|
|
# "<Tytuł> - HQporner.com" → utnij sufiks strony.
|
|
title = re.sub(r"\s*-\s*HQporner\.com\s*$", "", title, flags=re.IGNORECASE).strip()
|
|
if not title:
|
|
return None
|
|
|
|
duration_sec: int | None = None
|
|
dm = _DUR_RE.search(detail_html)
|
|
if dm and (dm.group(1) or dm.group(2)):
|
|
duration_sec = int(dm.group(1) or 0) * 60 + int(dm.group(2) or 0)
|
|
duration_sec = duration_sec or None
|
|
|
|
performers: list[RawPerformer] = []
|
|
seen_perf: set[str] = set()
|
|
for slug, disp in _ACTRESS_RE.findall(detail_html):
|
|
name = _clean(disp) or slug.replace("-", " ").title()
|
|
key = slugify(name)
|
|
if not key or key in seen_perf:
|
|
continue
|
|
seen_perf.add(key)
|
|
performers.append(
|
|
RawPerformer(external_id=f"{self.sitetag}:performer:{key}", name=name)
|
|
)
|
|
|
|
tags: list[RawTag] = []
|
|
seen_tag: set[str] = set()
|
|
for slug in _CATEGORY_RE.findall(detail_html):
|
|
slug = slug.lower()
|
|
if slug in seen_tag or _QUALITY_TAG_RE.match(slug):
|
|
continue
|
|
seen_tag.add(slug)
|
|
tags.append(
|
|
RawTag(
|
|
external_id=f"{self.sitetag}:tag:{slug}",
|
|
name=slug.replace("-", " ").title(),
|
|
slug=slug,
|
|
)
|
|
)
|
|
|
|
# Tylko okladka z kafelka listingu. Brak kafelka = brak miniatury, nigdy obrazek ze
|
|
# strony filmu (patrz _COVER_RE).
|
|
vid = _SCENE_HREF_RE.search(scene_url)
|
|
thumbnail_url = getattr(self, "_covers", {}).get(vid.group(1)) if vid else None
|
|
|
|
fingerprints: list[RawFingerprint] = []
|
|
if thumbnail_url:
|
|
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
|
if ph:
|
|
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
|
|
|
return RawScene(
|
|
# Ten sam format co HQPornerScraper.search, inaczej duplikat sceny.
|
|
external_id=f"{self.sitetag}:{scene_url}",
|
|
title=title,
|
|
duration_sec=duration_sec,
|
|
url=scene_url,
|
|
performers=performers,
|
|
tags=tags,
|
|
fingerprints=fingerprints,
|
|
playback_sources=[
|
|
RawPlaybackSource(
|
|
origin=f"tube:{self.sitetag}",
|
|
page_url=scene_url,
|
|
duration_sec=duration_sec,
|
|
thumbnail_url=thumbnail_url,
|
|
)
|
|
],
|
|
raw={"source": "direct_scraper:hqporner_browse", "url": scene_url},
|
|
)
|