Oba serwisy kasuja filmy po cichu: strona odpowiada 200 z "Post Not Found" albo "Video has been deleted". Probka 2026-09-19: 57% zywych w bazie zrodel sxyprn w wieku 10-40 dni i 22% eporner bylo juz usunietych; w apce to puste kafelki i "the tube removed" po kliknieciu. - eporner: ekstraktor rozpoznaje usunieta strone i rzuca HosterDead, wiec resolve oznacza zrodlo zamiast oddawac "blad chwilowy" (82 takie wejscia w 48 h, nic nie oznaczal). - proxy miniatur: "Post Not Found" na sxyprn oznacza zrodlo od razu, 404 na miniaturze eporner uruchamia w tle sprawdzenie strony (28/30 takich miniatur to usuniete filmy). Indeks hash po thumbnail_url tylko dla eporner (0044), zeby znalezc zrodlo po obrazku. - job dead-reap co 6 h: po 1500 zywych zrodel na serwis, polowa z ostatnich 30 dni. Oznacza tylko przy pewnym sygnale; siec i timeouty zostawia. Sprawdzone na produkcji: 10/10 miniatur eporner i 23/23 postow sxyprn oznaczone, 17 zywych postow nietknietych. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
4.8 KiB
Python
126 lines
4.8 KiB
Python
"""Cykliczne sprzatanie martwych zrodel sxyprn i eporner (2026-09-19).
|
|
|
|
Oba serwisy kasuja filmy szybko i po cichu: strona dalej odpowiada 200, tylko z
|
|
komunikatem ("Post Not Found" na sxyprn, "Video has been deleted" na eporner). Probka
|
|
2026-09-19: 57% zywych w bazie zrodel sxyprn w wieku 10-40 dni i 22% eporner bylo juz
|
|
usunietych. W apce to puste kafelki i "the tube removed this video" po kliknieciu.
|
|
|
|
Dwie drogi, ktore sie uzupelniaja:
|
|
- app/dead_sources.py oznacza to, co ktos wlasnie przewija (sygnal z proxy miniatur),
|
|
- ten job co kilka godzin sprawdza porcje reszty: polowe z ostatnich 30 dni, bo tam
|
|
kasowanie jest najczestsze, polowe losowo ze starszych. Bez kursora i bez stanu: kazdy
|
|
przebieg bierze tylko zywe zrodla, wiec oznaczone same wypadaja z puli.
|
|
|
|
Oznaczamy tylko przy pewnym sygnale (komunikat o usunieciu albo 404 strony). Siec, timeout,
|
|
rate-limit i strona bez playera to "nie wiadomo" i zrodlo zostaje. Wolno i grzecznie
|
|
(3 watki, odstep przed kazdym pobraniem), bo sxyprn to nasze najczesciej ogladane zrodlo i
|
|
ban IP bylby gorszy niz martwe kafelki. Pelny przebieg po zaleglosciach:
|
|
scripts/sxyprn_reap_dead.py.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import collections
|
|
import logging
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.db import session_scope
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_CONCURRENCY = 3
|
|
_SPACING_SEC = 0.5
|
|
_TIMEOUT = 20.0
|
|
_ALIVE, _DEAD, _UNKNOWN = "alive", "dead", "unknown"
|
|
|
|
|
|
def _probe_sxyprn(page_url: str) -> str:
|
|
from app.extractors._models import HosterDead
|
|
from app.extractors.tubes import sxyprn
|
|
|
|
try:
|
|
res = sxyprn.extract(page_url, timeout=_TIMEOUT)
|
|
except HosterDead:
|
|
return _DEAD
|
|
except Exception:
|
|
return _UNKNOWN
|
|
return _ALIVE if res and getattr(res[0], "link", None) else _UNKNOWN
|
|
|
|
|
|
def _probe_eporner(page_url: str) -> str:
|
|
from app.extractors._fetch import fetch_tube_html
|
|
from app.extractors._models import TubePageError
|
|
from app.extractors.tubes.eporner import is_deleted_page
|
|
|
|
try:
|
|
html = fetch_tube_html(page_url, timeout=_TIMEOUT)
|
|
except TubePageError as e:
|
|
return _DEAD if e.status_code in (404, 410) else _UNKNOWN
|
|
except Exception:
|
|
return _UNKNOWN
|
|
if is_deleted_page(html):
|
|
return _DEAD
|
|
return _ALIVE if "EP.video.player.vid" in html else _UNKNOWN
|
|
|
|
|
|
_PROBES = {
|
|
"tube:sxyprncom": ("sxyprn-reap: Post Not Found", _probe_sxyprn),
|
|
"tube:epornercom": ("eporner-reap: video has been deleted", _probe_eporner),
|
|
}
|
|
|
|
|
|
def _pick(origin: str, per_run: int) -> list[tuple[str, str]]:
|
|
half = per_run // 2
|
|
with session_scope() as s:
|
|
recent = s.execute(
|
|
text(
|
|
"SELECT id::text, page_url FROM playback_sources "
|
|
"WHERE origin = :o AND dead_at IS NULL AND page_url IS NOT NULL "
|
|
"AND created_at > now() - interval '30 days' ORDER BY random() LIMIT :n"
|
|
),
|
|
{"o": origin, "n": half},
|
|
).all()
|
|
older = s.execute(
|
|
text(
|
|
"SELECT id::text, page_url FROM playback_sources "
|
|
"WHERE origin = :o AND dead_at IS NULL AND page_url IS NOT NULL "
|
|
"AND created_at <= now() - interval '30 days' ORDER BY random() LIMIT :n"
|
|
),
|
|
{"o": origin, "n": per_run - len(recent)},
|
|
).all()
|
|
return [(r[0], r[1]) for r in recent + older]
|
|
|
|
|
|
def run_dead_reap(*, per_origin: int = 2000) -> dict[str, dict[str, int]]:
|
|
"""Sprawdza po `per_origin` zywych zrodel sxyprn i eporner, oznacza usuniete."""
|
|
out: dict[str, dict[str, int]] = {}
|
|
for origin, (reason, probe) in _PROBES.items():
|
|
t0 = time.time()
|
|
rows = _pick(origin, per_origin)
|
|
cnt: collections.Counter = collections.Counter()
|
|
dead_ids: list[str] = []
|
|
|
|
def work(row: tuple[str, str], _probe=probe) -> tuple[str, str]:
|
|
time.sleep(_SPACING_SEC)
|
|
return row[0], _probe(row[1])
|
|
|
|
with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
|
|
for sid, verdict in pool.map(work, rows):
|
|
cnt[verdict] += 1
|
|
if verdict == _DEAD:
|
|
dead_ids.append(sid)
|
|
marked = 0
|
|
for i in range(0, len(dead_ids), 500):
|
|
with session_scope() as s:
|
|
marked += s.execute(
|
|
text(
|
|
"UPDATE playback_sources SET dead_at = now(), dead_reason = :r "
|
|
"WHERE id = ANY(CAST(:ids AS uuid[])) AND dead_at IS NULL"
|
|
),
|
|
{"r": reason, "ids": dead_ids[i : i + 500]},
|
|
).rowcount
|
|
out[origin] = {"checked": len(rows), **cnt, "marked": marked}
|
|
log.info("dead-reap %s: %s w %.0f s", origin, out[origin], time.time() - t0)
|
|
return out
|