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
209 lines
7.9 KiB
Python
209 lines
7.9 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, gone). Sieć i parsowanie, bez dotykania bazy.
|
|
|
|
`gone` = strona odpowiedziała 404/410, więc kolejne próby nic nie dadzą i wiersz
|
|
można zamknąć od razu zamiast odwiedzać martwy adres trzy razy. Błąd sieci, 5xx
|
|
albo 403 (limit, geo) zostają zwykłą nieudaną próbą.
|
|
"""
|
|
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 in (404, 410):
|
|
return (scene_id, None, True)
|
|
if r.status_code >= 400:
|
|
return (scene_id, None, False)
|
|
return (scene_id, extract_duration_for_origin(origin, r.text, page_url), False)
|
|
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, False)
|
|
|
|
|
|
def _take_batch(session, *, batch: int) -> list[tuple]:
|
|
"""Paczka po rowno z kazdego originu, ktory ma jeszcze cos w kolejce.
|
|
|
|
Kolejnosc dopisania (`created_at`) jako jedyny klucz oznaczalaby, ze origin dopisany
|
|
pozniej (perverzija, mypornerleak, 2026-10-14) czeka, az 900 tys. wierszy xhamster,
|
|
xvideos i xnxx sie wyczerpie. Zamiast tego limit dzielimy miedzy originy z otwartymi
|
|
wierszami (najmniejsze kolejki dostaja swoj udzial w calosci, reszta dzieli resztę),
|
|
a wewnatrz originu zostaje stary porzadek: najpierw nieprobowane, potem najstarsze.
|
|
"""
|
|
remaining = session.execute(
|
|
text(
|
|
"""
|
|
SELECT origin, count(*) AS n
|
|
FROM duration_backfill_queue
|
|
WHERE done_at IS NULL AND attempts < :max_attempts
|
|
GROUP BY origin
|
|
"""
|
|
),
|
|
{"max_attempts": _MAX_ATTEMPTS},
|
|
).fetchall()
|
|
pending = sorted(((int(n), o) for o, n in remaining if n), key=lambda t: t[0])
|
|
rows: list[tuple] = []
|
|
left = batch
|
|
for i, (n, origin) in enumerate(pending):
|
|
quota = min(n, max(1, left // (len(pending) - i)))
|
|
if quota <= 0:
|
|
break
|
|
rows += [
|
|
tuple(r)
|
|
for r in session.execute(
|
|
text(
|
|
"""
|
|
SELECT scene_id, page_url, origin
|
|
FROM duration_backfill_queue
|
|
WHERE origin = :origin AND done_at IS NULL AND attempts < :max_attempts
|
|
ORDER BY attempts, created_at
|
|
LIMIT :quota
|
|
"""
|
|
),
|
|
{"origin": origin, "max_attempts": _MAX_ATTEMPTS, "quota": quota},
|
|
).fetchall()
|
|
]
|
|
left -= quota
|
|
if left <= 0:
|
|
break
|
|
return rows
|
|
|
|
|
|
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 = _take_batch(s, batch=batch)
|
|
|
|
if not rows:
|
|
return {"taken": 0, "filled": 0, "missed": 0, "gone": 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, _gone in results if dur]
|
|
gone = [sid for sid, dur, is_gone in results if not dur and is_gone]
|
|
missed = [sid for sid, dur, is_gone in results if not dur and not is_gone]
|
|
|
|
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 gone:
|
|
s.execute(
|
|
text(
|
|
"""
|
|
UPDATE duration_backfill_queue
|
|
SET attempts = :max_attempts, done_at = now()
|
|
WHERE scene_id = ANY(:ids)
|
|
"""
|
|
),
|
|
{"ids": gone, "max_attempts": _MAX_ATTEMPTS},
|
|
)
|
|
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),
|
|
"gone": len(gone),
|
|
}
|