feat(ingest): backfill brakujacej dlugosci scen

1 755 421 scen nie ma dlugosci ani na scenie, ani na zrodle, wiec wypadaja z
feedu: w SQL "duration_sec >= 60" nie jest prawda dla NULL, przez co scena o
nieznanej dlugosci znika tak samo jak dziesieciosekundowy smiec. Filtr zostaje,
bo odcina smieci, uzupelniamy dane.

Swiadomie waskie originy. Uniwersalny regex trafia w smieci: porntrex ma w HTML
"duration: 5" reklamy, mypornerleak dlugosc ciastka 720, a xhamster i porntrex
wypisuja dlugosci filmow z paska obok (naiwne dopasowanie dalo 198s zamiast 240s).
Zla dlugosc jest gorsza niz brak, bo dedup opiera sie na niej najmocniej.

- xvideos, xnxx: VideoObject LD-JSON, 5/5 na probce
- xhamster: videoEntity z kontrola zgodnosci id z URL-a
- porntrex pominiety: strona szczegolow nie podaje wlasnej dlugosci

Kolejka jak przy phashu (plan raz, mielenie paczkami, przezywa restart):
845 899 scen, 500 stron co 5 min, czyli ok. 6 dni. Plan powtarza sie co 24h,
zeby swieze sceny bez dlugosci tez trafialy do kolejki.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-08-20 12:10:22 +02:00
parent cddf60acc2
commit 544de9c5ba
8 changed files with 409 additions and 0 deletions

View file

@ -0,0 +1,55 @@
"""duration_backfill_queue: kolejka scen do doliczenia dlugosci ze strony tuba
Uzasadnienie i liczby: `app/models/duration_backfill_queue.py`.
Revision ID: 0031_duration_backfill
Revises: 0030_scenes_quality_idx
Create Date: 2026-08-20
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# Uwaga: `alembic_version.version_num` to varchar(32), dluzsze id nie wejdzie.
revision: str = "0031_duration_backfill"
down_revision: str | None = "0030_scenes_quality_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"duration_backfill_queue",
sa.Column("scene_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("page_url", sa.String(length=1024), nullable=False),
sa.Column("origin", sa.String(length=64), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("done_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["scene_id"], ["scenes.id"], ondelete="CASCADE",
name="fk_duration_backfill_queue_scene_id_scenes",
),
sa.PrimaryKeyConstraint("scene_id", name="pk_duration_backfill_queue"),
)
# Paczka bierze wiersze niezrobione, po originie (zeby nie zawalic jednego hosta).
op.create_index(
"ix_duration_backfill_queue_pending",
"duration_backfill_queue",
["origin", "attempts"],
postgresql_where=sa.text("done_at IS NULL"),
)
def downgrade() -> None:
op.drop_index(
"ix_duration_backfill_queue_pending", table_name="duration_backfill_queue"
)
op.drop_table("duration_backfill_queue")

View file

@ -138,6 +138,16 @@ class Settings(BaseSettings):
sched_phash_backfill_batch: int = Field(
default=800, validation_alias="GOON_SCHED_PHASH_BACKFILL_BATCH"
)
# Doliczanie brakujacej dlugosci scen (patrz app/models/duration_backfill_queue.py).
# 500 stron co 5 min to ~144 tys. dziennie, wiec kolejka 846 tys. schodzi w ok. 6 dni.
# To ~1,7 zapytania na sekunde rozlozone na trzy duze tuby, czyli ruch, ktorego one
# nie zauwaza. Job ma 30-minutowy limit, a paczka schodzi w ok. 3 min, wiec z zapasem.
sched_duration_backfill_minutes: int = Field(
default=5, validation_alias="GOON_SCHED_DURATION_BACKFILL_MINUTES"
)
sched_duration_backfill_batch: int = Field(
default=500, validation_alias="GOON_SCHED_DURATION_BACKFILL_BATCH"
)
# Próg ciszy dla konektorów FILMOWYCH. Wcześniej filmy nie były pokryte watchdogiem
# w ogóle, streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
# codziennie, więc 72h to bezpieczny margines na jeden nieudany cykl.

View file

@ -89,3 +89,68 @@ def extract_duration_sec(html: str) -> int | None:
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
#: Origin → wzorzec. Dopisuj TYLKO to, co sprawdziłeś na próbce prawdziwych stron;
#: porntrex i mypornerleak celowo tu nie ma, patrz `app/models/duration_backfill_queue.py`.
ORIGIN_DURATION_RESOLVERS = {
"tube:xhamstercom": _extract_xhamster,
"tube:xvideoscom": _extract_ldjson_only,
"tube:xnxxcom": _extract_ldjson_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

View file

@ -20,6 +20,7 @@ from app.models.performer import Performer, PerformerAlias, PerformerExternalRef
from app.models.play_progress import MoviePlayProgress, ScenePlayProgress
from app.models.playback_event import PlaybackEvent
from app.models.playback_source import PlaybackSource
from app.models.duration_backfill_queue import DurationBackfillQueue
from app.models.phash_backfill_queue import PhashBackfillQueue
from app.models.phash_blacklist import PhashBlacklist
from app.models.saved_search import SavedSearch

View file

@ -0,0 +1,67 @@
"""Kolejka scen, którym trzeba doliczyć długość ze strony tuba.
Filtr `min_duration_sec=60` odcina śmieciowe klipy i tak ma zostać, ale w SQL-u
`duration_sec >= 60` NIE jest prawdą dla NULL, więc scena o NIEZNANEJ długości znika
z feedu tak samo jak dziesięciosekundowy śmieć. Audyt 2026-08-20: **1 755 421 scen**
nie ma długości ani na scenie, ani na źródle, czyli scraper nigdy jej nie zebrał.
Największe zaległości to porntrex (676 tys.), xhamster (361 tys.), xvideos (259 tys.)
i xnxx (226 tys.).
**Dlaczego nie jeden uniwersalny regex po stronie szczegółów.** Bo trafia w śmieci.
Na próbce: porntrex ma w HTML `duration: '5'` (długość reklamy), mypornerleak
`disclaimer_cookie_duration":"720"` (ciastko), a xhamster i porntrex wypisują długości
WSZYSTKICH filmów powiązanych z paska obok. Naiwne dopasowanie przy jednym xhamsterze
dało 198 s zamiast prawdziwych 240 s. Zła długość jest gorsza niż jej brak, bo dedup
traktuje długość jako najmocniejszy pojedynczy sygnał i skleiłby obce sceny.
Dlatego backfill obsługuje tylko originy z POTWIERDZONYM, punktowym wzorcem
(`app/extractors/duration_extract.py`), a nie wszystko, co się nawinie:
* xvideos, xnxx: `VideoObject` LD-JSON z ISO 8601, sprawdzone 5/5
* xhamster: `"videoEntity":{"id":<id z URL>,…,"duration":<sekundy>}`, z kontrolą
zgodności id, bo bez niej łapie film z paska obok
porntrex świadomie NIE jest tu obsługiwany: jego strona szczegółów w ogóle nie podaje
własnej długości (`video_duration` w flashvars nie występuje), a jedyne `durations`
w HTML należą do filmów powiązanych. Dla niego drogą jest listing, bo kafelek długość
pokazuje i `porntrex_browse.py` już ją stamtąd czyta.
Kolejka jest tabelą z tego samego powodu co przy phashu: wyznaczenie kandydatów to
ciężki przegląd całego katalogu, a mielenie ma przeżyć restart workera i pokazywać
postęp. Patrz [[phash_backfill_queue]] dla bliźniaczego wzorca.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class DurationBackfillQueue(Base):
__tablename__ = "duration_backfill_queue"
scene_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("scenes.id", ondelete="CASCADE"),
primary_key=True,
)
#: Strona tuba, z której czytamy długość. Zapisana przy planowaniu, żeby mielenie
#: nie musiało co paczkę wracać do `playback_sources`.
page_url: Mapped[str] = mapped_column(String(1024), nullable=False)
#: Decyduje, KTÓRY punktowy wzorzec zostanie użyty. Bez tego wracamy do zgadywania.
origin: Mapped[str] = mapped_column(String(64), nullable=False)
#: Rośnie przy każdej nieudanej próbie; po wyczerpaniu limitu wiersz jest odkładany,
#: żeby skasowana strona nie blokowała kolejki w nieskończoność.
attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
#: Ustawione po sukcesie ALBO po wyczerpaniu prób (wtedy długość została pusta).
done_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View file

@ -0,0 +1,148 @@
"""Dolicza brakującą długość scenom ze stron tubów, paczkami.
Po co i dlaczego tylko wybrane originy: `app/models/duration_backfill_queue.py`.
W skrócie: bez długości scena wypada z feedu, bo `duration_sec >= 60` nie jest prawdą
dla NULL, a zła długość jest gorsza niż brak, bo dedup opiera się na niej najmocniej.
Stąd wyłącznie wzorce punktowe z `ORIGIN_DURATION_RESOLVERS`.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import text
from app.db import session_scope
from app.extractors.duration_extract import (
ORIGIN_DURATION_RESOLVERS,
extract_duration_for_origin,
)
log = logging.getLogger(__name__)
# Po tylu nieudanych próbach odkładamy wiersz. Strony bywają skasowane i nie ma sensu
# wracać do nich w kółko, blokując kolejkę.
_MAX_ATTEMPTS = 3
_DEFAULT_BATCH = 500
# Sześć wątków na paczkę, a paczki są rozłożone w czasie przez scheduler. Pobrania
# rozkładają się na trzy różne serwisy, więc żaden nie dostaje serii pod rząd.
_CONCURRENCY = 6
_FETCH_TIMEOUT = 20.0
def plan_duration_backfill(limit: int | None = None) -> int:
"""Wrzuca do kolejki sceny bez długości, mające źródło na obsługiwanym originie.
Idempotentne: `ON CONFLICT DO NOTHING`, więc można wołać wielokrotnie, także po
dopisaniu nowego originu do `ORIGIN_DURATION_RESOLVERS`.
"""
origins = sorted(ORIGIN_DURATION_RESOLVERS)
if not origins:
return 0
sql = """
INSERT INTO duration_backfill_queue (scene_id, page_url, origin)
SELECT DISTINCT ON (s.id) s.id, ps.page_url, ps.origin
FROM scenes s
JOIN playback_sources ps ON ps.scene_id = s.id
WHERE s.duration_sec IS NULL
AND ps.origin = ANY(:origins)
AND ps.page_url IS NOT NULL
AND ps.dead_at IS NULL
ORDER BY s.id, ps.origin
ON CONFLICT (scene_id) DO NOTHING
"""
if limit is not None:
# LIMIT musi objąć SELECT, nie INSERT, stąd podzapytanie przy ograniczonym planie.
sql = f"""
INSERT INTO duration_backfill_queue (scene_id, page_url, origin)
SELECT * FROM (
SELECT DISTINCT ON (s.id) s.id, ps.page_url, ps.origin
FROM scenes s
JOIN playback_sources ps ON ps.scene_id = s.id
WHERE s.duration_sec IS NULL
AND ps.origin = ANY(:origins)
AND ps.page_url IS NOT NULL
AND ps.dead_at IS NULL
ORDER BY s.id, ps.origin
) q LIMIT {int(limit)}
ON CONFLICT (scene_id) DO NOTHING
"""
with session_scope() as s:
n = s.execute(text(sql), {"origins": origins}).rowcount
log.info("duration-backfill: zaplanowano %d scen (originy: %s)", n, ",".join(origins))
return n
def _fetch_one(row: tuple) -> tuple:
"""(scene_id, duration|None). Sieć i parsowanie, bez dotykania bazy."""
scene_id, page_url, origin = row
from app.extractors._fetch import browser_get
try:
r = browser_get(page_url, timeout=_FETCH_TIMEOUT, follow_redirects=True)
if r.status_code >= 400:
return (scene_id, None)
return (scene_id, extract_duration_for_origin(origin, r.text, page_url))
except Exception as e: # pojedyncza strona nie może wywrócić paczki
log.debug("duration-backfill: %s padlo (%s)", page_url, e)
return (scene_id, None)
def run_duration_backfill(*, batch: int = _DEFAULT_BATCH) -> dict[str, int]:
"""Bierze paczkę z kolejki, dolicza długość, zapisuje. Pusta kolejka = no-op."""
with session_scope() as s:
rows = s.execute(
text(
"""
SELECT scene_id, page_url, origin
FROM duration_backfill_queue
WHERE done_at IS NULL AND attempts < :max_attempts
ORDER BY attempts, created_at
LIMIT :batch
"""
),
{"max_attempts": _MAX_ATTEMPTS, "batch": batch},
).fetchall()
if not rows:
return {"taken": 0, "filled": 0, "missed": 0}
with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
results = list(pool.map(_fetch_one, [tuple(r) for r in rows]))
filled = [(sid, dur) for sid, dur in results if dur]
missed = [sid for sid, dur in results if not dur]
with session_scope() as s:
for scene_id, dur in filled:
# Warunek na NULL, bo między planem a zapisem scena mogła zostać scalona
# albo wzbogacona z kanonu, a ten jest wiarygodniejszy niż tube.
s.execute(
text(
"UPDATE scenes SET duration_sec = :d "
" WHERE id = :id AND duration_sec IS NULL"
),
{"d": dur, "id": scene_id},
)
s.execute(
text(
"UPDATE duration_backfill_queue SET done_at = now() WHERE scene_id = :id"
),
{"id": scene_id},
)
if missed:
# Wyczerpane próby zamykamy, żeby kolejka nie kręciła się w kółko na martwych.
s.execute(
text(
"""
UPDATE duration_backfill_queue
SET attempts = attempts + 1,
done_at = CASE WHEN attempts + 1 >= :max_attempts
THEN now() ELSE NULL END
WHERE scene_id = ANY(:ids)
"""
),
{"ids": missed, "max_attempts": _MAX_ATTEMPTS},
)
return {"taken": len(rows), "filled": len(filled), "missed": len(missed)}

View file

@ -292,6 +292,38 @@ def _job_phash_backfill(batch: int) -> None:
log.exception("[scheduler] phash backfill failed")
def _job_duration_plan() -> None:
"""Dopisuje do kolejki sceny, ktore od wczoraj przyszly bez dlugosci. Scrapery
czesci tubow jej nie zbieraja przy ingescie, wiec bez tego zaleglosc odrastalaby
z kazda doba. `ON CONFLICT DO NOTHING`, wiec powtorzenie nic nie psuje."""
try:
from app.scheduler.duration_backfill import plan_duration_backfill
added = _run_with_timeout(
lambda: plan_duration_backfill(), label="duration-plan"
)
log.info("[scheduler] duration plan done (%s)", added)
except Exception:
log.exception("[scheduler] duration plan failed")
def _job_duration_backfill(batch: int) -> None:
"""Dolicza dlugosc scenom, ktore jej nie maja. Bez dlugosci scena wypada z feedu,
bo `duration_sec >= 60` nie jest prawda dla NULL. Kolejke planujemy raz
(`plan_duration_backfill`), tu tylko mielimy paczkami, bo to pobrania cudzych stron.
Pusta kolejka = no-op, wiec nic nie kosztuje."""
try:
from app.scheduler.duration_backfill import run_duration_backfill
out = _run_with_timeout(
lambda: run_duration_backfill(batch=batch), label="duration-backfill"
)
if out and out.get("taken"):
log.info("[scheduler] duration backfill %s", out)
except Exception:
log.exception("[scheduler] duration backfill failed")
def _job_phash_blacklist() -> None:
"""Wyłapuje phashe, które przestały cokolwiek znaczyć (zaślepka / czarna klatka /
intro studia) i kasuje je z fingerprintów. Audyt 2026-07-27: 73 takie wartości przy
@ -547,6 +579,35 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
)
log.info("scheduler: phash blacklist every %dh", cfg["phash_blacklist_hours"])
if cfg.get("duration_backfill_minutes"):
sched.add_job(
_job_duration_plan,
IntervalTrigger(hours=24, start_date=INTERVAL_ANCHOR),
id="duration_plan",
replace_existing=True,
max_instances=1,
coalesce=True,
)
log.info("scheduler: duration plan every 24h")
if cfg.get("duration_backfill_minutes"):
_dur_batch = int(cfg.get("duration_backfill_batch") or 300)
sched.add_job(
_job_duration_backfill,
IntervalTrigger(
minutes=cfg["duration_backfill_minutes"], start_date=INTERVAL_ANCHOR
),
args=[_dur_batch],
id="duration_backfill",
replace_existing=True,
max_instances=1,
coalesce=True,
)
log.info(
"scheduler: duration backfill every %dmin (batch=%d)",
cfg["duration_backfill_minutes"],
_dur_batch,
)
if cfg.get("phash_backfill_minutes"):
_batch = int(cfg.get("phash_backfill_batch") or 400)
sched.add_job(

View file

@ -219,6 +219,8 @@ def run_forever() -> int:
# Blacklista zdegenerowanych phashy, zaślepki dzielone przez setki scen (audyt
# 2026-07-27). Bez tego zaślepka jest zawsze najbliższym trafieniem w Path 3.
"phash_blacklist_hours": getattr(settings, "sched_phash_blacklist_hours", 24) or None,
"duration_backfill_minutes": getattr(settings, "sched_duration_backfill_minutes", 5) or None,
"duration_backfill_batch": getattr(settings, "sched_duration_backfill_batch", 500),
"phash_backfill_minutes": getattr(settings, "sched_phash_backfill_minutes", 20) or None,
"phash_backfill_batch": getattr(settings, "sched_phash_backfill_batch", 400),
# Śmieciowi performerzy, kategorie/studia udające osoby (audyt 2026-07-27).