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>
148 lines
5.6 KiB
Python
148 lines
5.6 KiB
Python
"""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)}
|