Some checks are pending
Backend tests / test (push) Waiting to run
- browse_runs (migracja 0056): jeden wiersz per scraper i przebieg browse-latest - quality_watchdog: listing pusty albo bez nowych, skok lub zanik obsady, sceny bez dlugosci i miniatur, spadek udanych odtworzen per origin; fingerprint per zrodlo i rodzaj - ingest_watchdog: prog swiezosci z mediany odstepow zrodla (3x, max 14 d) zamiast stalej - duration backfill: resolvery perverzija (JSON-LD) i mypornerleak (itemprop), paczka po rowno z originow, 404/410 zamyka wiersz od razu - parsery: galaxyporn data z JSON-LD, hentaimama dlugosc, javflix aktorki, fullmovies i hdporngg data z uploadDate
173 lines
7 KiB
Python
173 lines
7 KiB
Python
"""Universal duration extractor for tube pages.
|
|
|
|
Direct scrapery (xvideos, xnxx, youporn, porntrex, …) są search-only, pobierają
|
|
listing i wycioskują tylko URL + slug-as-title. Duration pojawia się dopiero na
|
|
detail page i jest dostępne w jednym z patternów:
|
|
|
|
1. **OpenGraph numeric** (youporn, redtube, eporner):
|
|
`<meta property="og:video:duration" content="992">`, sekundy.
|
|
2. **OpenGraph ISO 8601** (rzadkie):
|
|
`<meta property="og:video:duration" content="PT16M32S">`.
|
|
3. **Schema.org VideoObject LD-JSON** (xvideos, xnxx, KVS-based):
|
|
`"duration": "PT00H07M10S"` w JSON-LD `<script type="application/ld+json">`.
|
|
4. **itemprop microdata** (sxyland, 0dayxx, niektóre WordPress):
|
|
`<meta itemprop="duration" content="P0DT0H21M13S">`, ISO 8601 z opcjonalnym
|
|
`P<days>D` prefix + opcjonalnym `T` blokiem HMS.
|
|
|
|
Funkcja zwraca pierwszy znaleziony match jako int seconds, lub None.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
_OG_DURATION_RE = re.compile(
|
|
r'<meta\s+property="(?:og:(?:video:)?|video:)duration"\s+content="([^"]+)"',
|
|
re.IGNORECASE,
|
|
)
|
|
_LD_DURATION_RE = re.compile(r'"duration"\s*:\s*"(P[0-9DTHMS]+)"', re.IGNORECASE)
|
|
_ITEMPROP_DURATION_RE = re.compile(
|
|
r'itemprop="duration"[^>]*content="([^"]+)"', re.IGNORECASE
|
|
)
|
|
# Hqporner-style meta description: "Video duration is 6min 55sec" lub "1h 23min 5sec".
|
|
# Generic, pasuje też do innych tube'ów które dorzucają w meta opis duration prozą.
|
|
_META_DESC_DURATION_RE = re.compile(
|
|
r'(?:duration\s+is\s+|<meta\s+name="description"\s+content="[^"]*duration\s+is\s+)'
|
|
r'(?:(\d+)\s*h(?:our)?s?)?\s*(?:(\d+)\s*min)?\s*(?:(\d+)\s*sec)?',
|
|
re.IGNORECASE,
|
|
)
|
|
# Generalized ISO 8601: P[<n>D][T[<n>H][<n>M][<n>S]]. Pokrywa `PT16M32S`,
|
|
# `PT00H07M10S`, `P0DT0H21M13S` jednocześnie. Dni są rzadko sensowne (>24h scena),
|
|
# ale zachowujemy bo niektóre tube'y wpisują P0D dla porządku.
|
|
_ISO_DURATION_RE = re.compile(
|
|
r"^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$", re.IGNORECASE
|
|
)
|
|
|
|
|
|
def _parse_iso8601(value: str) -> int | None:
|
|
"""`P0DT0H21M13S` → 1273, `PT00H07M10S` → 430. None gdy format niepasujący
|
|
LUB total == 0 (sygnał placeholder bez duration)."""
|
|
m = _ISO_DURATION_RE.match(value.strip())
|
|
if not m:
|
|
return None
|
|
d, h, mi, s = (int(g) if g else 0 for g in m.groups())
|
|
total = d * 86400 + h * 3600 + mi * 60 + s
|
|
return total if total > 0 else None
|
|
|
|
|
|
def extract_duration_sec(html: str) -> int | None:
|
|
"""Zwraca duration w sekundach lub None gdy żaden wzorzec nie pasuje.
|
|
|
|
Kolejność: OG numeric → OG ISO → LD-JSON ISO → itemprop ISO. Pierwsze pasujące
|
|
z `total > 0` wygrywa.
|
|
"""
|
|
if not html:
|
|
return None
|
|
|
|
if (m := _OG_DURATION_RE.search(html)):
|
|
v = m.group(1).strip()
|
|
if v.isdigit():
|
|
n = int(v)
|
|
if n > 0:
|
|
return n
|
|
if v.upper().startswith("P") and (parsed := _parse_iso8601(v)) is not None:
|
|
return parsed
|
|
|
|
if (m := _LD_DURATION_RE.search(html)):
|
|
if (parsed := _parse_iso8601(m.group(1))) is not None:
|
|
return parsed
|
|
|
|
if (m := _ITEMPROP_DURATION_RE.search(html)):
|
|
v = m.group(1).strip()
|
|
if v.upper().startswith("P") and (parsed := _parse_iso8601(v)) is not None:
|
|
return parsed
|
|
|
|
# Hqporner: "Video duration is 6min 55sec" w meta description.
|
|
if (m := _META_DESC_DURATION_RE.search(html)):
|
|
h, mi, s = (int(g) if g else 0 for g in m.groups())
|
|
total = h * 3600 + mi * 60 + s
|
|
if total > 0:
|
|
return total
|
|
|
|
return None
|
|
|
|
|
|
# --- Wzorce punktowe, per origin --------------------------------------------------
|
|
#
|
|
# `extract_duration_sec` jest generyczny i tym samym łatwowierny. Na stronach, które
|
|
# wypisują długości filmów z paska "powiązane", trafia w cudzą wartość, a na porntreksie
|
|
# w `duration: '5'` reklamy. Backfill katalogu (`app/scheduler/duration_backfill.py`)
|
|
# używa więc wyłącznie wzorców z potwierdzoną, punktową kotwicą.
|
|
|
|
# xhamster trzyma prawdziwą długość w `videoEntity`, obok id zgodnego z URL-em. Sprawdzone
|
|
# 2026-08-20: dla `.../macy-gray-i-try-pmv-10506445` videoEntity daje 240 s, a naiwne
|
|
# szukanie najbliższego `"duration"` dawało 198 s z filmu obok.
|
|
_XH_ENTITY_RE = re.compile(
|
|
r'"videoEntity"\s*:\s*\{(?P<body>.{0,600}?)\}', re.DOTALL
|
|
)
|
|
_XH_ID_RE = re.compile(r'"id"\s*:\s*(\d+)')
|
|
_XH_DUR_RE = re.compile(r'"duration"\s*:\s*(\d+)')
|
|
# id filmu jest na końcu sluga: `/videos/<slug>-<id>`.
|
|
_XH_URL_ID_RE = re.compile(r"-(\d{4,})(?:[/?#]|$)")
|
|
|
|
# Górna granica zdrowego rozsądku: 6 h. Wyżej to prawie na pewno zła wartość
|
|
# (sklejony live albo pomyłka jednostek), a długość idzie potem do dedupu.
|
|
_MAX_SANE_DURATION_SEC = 6 * 3600
|
|
|
|
|
|
def _extract_xhamster(html: str, page_url: str) -> int | None:
|
|
want = _XH_URL_ID_RE.search(page_url)
|
|
for m in _XH_ENTITY_RE.finditer(html):
|
|
body = m.group("body")
|
|
vid = _XH_ID_RE.search(body)
|
|
dur = _XH_DUR_RE.search(body)
|
|
if not dur:
|
|
continue
|
|
# Bez zgodności id wolimy nie zapisać nic, niż zapisać długość obcego filmu.
|
|
if want and vid and vid.group(1) != want.group(1):
|
|
continue
|
|
n = int(dur.group(1))
|
|
if 0 < n <= _MAX_SANE_DURATION_SEC:
|
|
return n
|
|
return None
|
|
|
|
|
|
def _extract_ldjson_only(html: str, page_url: str) -> int | None:
|
|
"""xvideos i xnxx: `VideoObject` z ISO 8601. Sprawdzone 5/5 na losowej próbce."""
|
|
if (m := _LD_DURATION_RE.search(html)) and (
|
|
parsed := _parse_iso8601(m.group(1))
|
|
) is not None:
|
|
if 0 < parsed <= _MAX_SANE_DURATION_SEC:
|
|
return parsed
|
|
return None
|
|
|
|
|
|
def _extract_itemprop_only(html: str, page_url: str) -> int | None:
|
|
"""mypornerleak: `<meta itemprop="duration" content="P0DT0H12M13S">` w <head> to
|
|
dlugosc TEJ sceny; karty powiazane maja tylko `<span class="duration">40:33</span>`,
|
|
ktorych tu nie czytamy. Sprawdzone 2026-10-14 na stronach z nowego listingu REST
|
|
(ingest przez wp-json nie niesie dlugosci, stad 76 tys. scen bez niej)."""
|
|
if (m := _ITEMPROP_DURATION_RE.search(html)):
|
|
v = m.group(1).strip()
|
|
if v.upper().startswith("P") and (parsed := _parse_iso8601(v)) is not None:
|
|
if 0 < parsed <= _MAX_SANE_DURATION_SEC:
|
|
return parsed
|
|
return None
|
|
|
|
|
|
#: Origin → wzorzec. Dopisuj TYLKO to, co sprawdziłeś na próbce prawdziwych stron;
|
|
#: porntrex celowo tu nie ma, patrz `app/models/duration_backfill_queue.py`.
|
|
#: perverzija: ingest z wp-json nie ma dlugosci, strona ma JSON-LD VideoObject
|
|
#: `"duration":"PT73M2S"` (2026-10-14, 21 tys. scen bez dlugosci).
|
|
ORIGIN_DURATION_RESOLVERS = {
|
|
"tube:xhamstercom": _extract_xhamster,
|
|
"tube:xvideoscom": _extract_ldjson_only,
|
|
"tube:xnxxcom": _extract_ldjson_only,
|
|
"tube:perverzijacom": _extract_ldjson_only,
|
|
"tube:mypornerleakcom": _extract_itemprop_only,
|
|
}
|
|
|
|
|
|
def extract_duration_for_origin(origin: str, html: str, page_url: str) -> int | None:
|
|
"""Długość dla originu z potwierdzonym wzorcem; None gdy origin nieobsługiwany."""
|
|
fn = ORIGIN_DURATION_RESOLVERS.get(origin)
|
|
return fn(html, page_url) if fn else None
|