feat(reap): pomiar martwych zrodel per tub, z kontrola pozytywna i negatywna
Po sxyprn (34% martwych na 188 tys. zrodel) nasuwa sie przemielenie tak samo calej reszty. Metoda sxyprn nie przenosi sie wprost: * Twardy sygnal smierci (`HosterDead`) ma tylko 6 z 42 ekstraktorow. Dla reszty "nic nie zwrocil" znaczy naraz "skasowane", "zmienil sie markup" i "rate limit". Wygaszanie na tej podstawie schowaloby ZYWE sceny, jak przy porntrex (6479 scen po cichej zmianie markupu). * Zywych zrodel jest ~3,9 mln, czyli ponad tydzien ciaglego pukania do wlasnej podazy. Dlatego najpierw pomiar. Skrypt jest wylacznie do odczytu, niczego nie oznacza. Martwe pada tylko na twardych sygnalach: HTTP 404/410 albo `HosterDead`. Pusty wynik ekstraktora to "nieznane" i zrodla nikt nie rusza. Kazdy tub dostaje DWIE kontrole, bo sam wynik proby niczego nie dowodzi: "0% martwych" znaczy albo "tub zdrowy", albo "nie widzimy trupow". * Pozytywna: zrodla scen, ktore realnie odegraly sie w ostatnich 14 dniach. Musza wrocic zywe. Lapie tuba, ktory zaczal oddawac 404 wszystkiemu bez sesji, czyli przypadek, w ktorym przemial skasowalby caly katalog. Prog to 5%, nie zero, bo czesc scen znika naprawde. Odsetek z kontroli odejmuje sie od wyniku proby. * Negatywna: prawdziwy adres z numerem sceny podmienionym na absurdalny. Taka strona nie moze istniec, wiec musi wrocic martwa. Negatywna od razu sie oplacila: pierwsza wersja pytala tylko o status HTTP i uznala sxyprn za tuba, na ktorym sonda jest slepa (0/6), chociaz wiemy, ze ma tam 34% trupow. Powod: sxyprn oddaje skasowany post jako HTTP 200, a `HosterDead` leci dopiero z ekstraktora. Po przejsciu na `try_extract` negatywna daje 7/7. Tuby resolvowane tylko w WebView na telefonie sa pomijane, bo z VPS sa nieweryfikowalne.
This commit is contained in:
parent
ecca52b8bd
commit
bbdff6bc90
1 changed files with 316 additions and 0 deletions
316
scripts/dead_source_survey.py
Normal file
316
scripts/dead_source_survey.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
"""Bada, ile martwych zrodel siedzi w kazdym tubie, ZANIM cokolwiek wygasimy.
|
||||
|
||||
Po sxyprn (34% martwych na 188 tys. zrodel) nasuwa sie, zeby przemielic tak samo cala
|
||||
reszte. Metoda sxyprn NIE przenosi sie wprost, z dwoch powodow:
|
||||
|
||||
1. **Tylko 6 z 42 ekstraktorow ma twardy sygnal smierci** (`HosterDead`). Dla reszty
|
||||
"ekstraktor nic nie zwrocil" znaczy jednoczesnie "post skasowany" i "zmienil sie
|
||||
markup" albo "dostalismy rate limit". Wygaszanie na takiej podstawie schowaloby ZYWE
|
||||
sceny, dokladnie tak jak przy porntrex (6479 scen zniknelo po cichej zmianie markupu).
|
||||
2. **Skala.** Zywych zrodel jest ~3,9 mln. W tempie sxyprn (~21 tys./h przy grzecznym
|
||||
odstepie) to ponad tydzien ciaglego pukania do naszej wlasnej podazy.
|
||||
|
||||
Dlatego najpierw pomiar, potem decyzja co w ogole warto mielic.
|
||||
|
||||
**Sygnal, ktory JEST generyczny:** `fetch_tube_html` podnosi `TubePageError` ze statusem
|
||||
dla kazdego tuba. HTTP 404/410 na stronie sceny to twarda smierc, niezalezna od tego, czy
|
||||
ekstraktor umie sparsowac strone. Zmiana markupu daje 200, wiec wpada do "nieznane" i nie
|
||||
rusza niczego. To odwraca ryzyko we wlasciwa strone.
|
||||
|
||||
**Soft-404** (HTTP 200 ze strona "nie znaleziono", jak sxyprn) lapiemy przez wolanie
|
||||
ekstraktora, bo to on podnosi `HosterDead`. Dla tubow BEZ ekstraktora zostaje sam status
|
||||
HTTP i takie soft-404 pozostaja niewidoczne: raport pokazuje wtedy trafienie we frazy typu
|
||||
"video not found", ale NIE UZYWA ich do werdyktu, bo taka fraza siedzi tez w zwyklej
|
||||
nawigacji strony. Sluzy to wylacznie do wskazania tubow wartych recznego obejrzenia.
|
||||
|
||||
**Dwie kontrole, bo sam wynik proby niczego nie dowodzi.** "0% martwych" znaczy albo
|
||||
"tub jest zdrowy", albo "nasza sonda nie widzi trupow". Bez rozroznienia tych dwoch
|
||||
przypadkow raport jest bezuzyteczny, wiec kazdy tub dostaje obie kontrole:
|
||||
|
||||
* **Pozytywna (fals-pozytywy):** zrodla scen, ktore REALNIE ODEGRALY sie z tego tuba w
|
||||
ostatnich 14 dniach (`playback_events.status='success'`). Musza wrocic ZYWE. Gdyby tub
|
||||
zaczal oddawac 404 wszystkiemu, co nie ma sesji albo wlasciwego IP, przemial skasowalby
|
||||
caly katalog, a to wyjdzie wlasnie tutaj.
|
||||
* **Negatywna (slepota):** prawdziwy adres z podmienionym numerem sceny na absurdalny.
|
||||
Taka strona NIE MOZE istniec, wiec musi wrocic MARTWA. Jesli wraca zywa, to tub oddaje
|
||||
200 na wszystko i o jego trupach ta metoda nie powie nic. Wynik proby dla takiego tuba
|
||||
trzeba zignorowac, a nie czytac jako "czysto".
|
||||
|
||||
Wygaszac wolno tylko tuby, ktore zdaly OBIE kontrole.
|
||||
|
||||
Uzycie (w kontenerze workera, przez `run -d --rm`, NIE `exec`, patrz nizej):
|
||||
python3 -u /srv/scripts/dead_source_survey.py --sample 150 --control 40
|
||||
python3 -u /srv/scripts/dead_source_survey.py --origins tube:porntrexcom
|
||||
|
||||
UWAGA NA RESTART: `docker compose restart worker` ubija zadania chodzace w kontenerze
|
||||
workera. Odpalaj przez `docker compose run -d --rm worker`, to osobny kontener.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import session_scope
|
||||
from app.extractors import is_vps_blocked_fallback, supported_sitetags, try_extract
|
||||
from app.extractors._fetch import fetch_tube_html
|
||||
from app.extractors._models import HosterDead, TubePageError
|
||||
|
||||
log = logging.getLogger("dead-survey")
|
||||
|
||||
_CONCURRENCY = 4
|
||||
_SPACING_SEC = 0.4
|
||||
_TIMEOUT = 25.0
|
||||
|
||||
# Frazy soft-404 tylko DO RAPORTU, nigdy do wygaszania. Wskazuja tuby warte obejrzenia
|
||||
# recznie (jak sxyprn, gdzie skasowany post oddaje HTTP 200 ze strona "Post Not Found").
|
||||
_SOFT404_HINTS = (
|
||||
"post not found",
|
||||
"video not found",
|
||||
"page not found",
|
||||
"has been deleted",
|
||||
"no longer available",
|
||||
"video was deleted",
|
||||
"video removed",
|
||||
"does not exist",
|
||||
)
|
||||
|
||||
|
||||
def _probe(item: tuple) -> tuple:
|
||||
"""(origin, rola, sid, werdykt, szczegol). Sama siec, bez dotykania bazy.
|
||||
|
||||
Idzie przez `try_extract`, a nie przez goly `fetch_tube_html`, i to jest istotne.
|
||||
Pierwsza wersja pytala tylko o status HTTP i przez to uznala sxyprn za tuba, na ktorym
|
||||
"sonda jest slepa" (0/6 w kontroli negatywnej), chociaz wiemy, ze ma tam 34% trupow.
|
||||
Powod: sxyprn oddaje skasowany post jako HTTP 200 ze strona "Post Not Found", a
|
||||
`HosterDead` leci dopiero z ekstraktora. Ta sama rzecz dotyczy porntrex, galaxyporn,
|
||||
paradisehill, watchporn i xhamster.
|
||||
|
||||
Werdykt "martwe" pada TYLKO na twardych sygnalach: 404/410 albo `HosterDead`. Gdy
|
||||
ekstraktor zwroci None (zmiana markupu, rate limit, cokolwiek), jest "nieznane" i
|
||||
zrodla nikt nie rusza. Odwrotne ustawienie schowaloby zywe sceny.
|
||||
"""
|
||||
origin, rola, sid, url = item
|
||||
sitetag = origin.split(":", 1)[-1]
|
||||
time.sleep(_SPACING_SEC)
|
||||
try:
|
||||
out = try_extract(sitetag, url)
|
||||
except TubePageError as e:
|
||||
if e.status_code in (404, 410):
|
||||
return (origin, rola, sid, "martwe", f"HTTP {e.status_code}")
|
||||
# 403, 5xx, rate limit: NIE jest dowodem na nic.
|
||||
return (origin, rola, sid, "nieznane", f"HTTP {e.status_code}")
|
||||
except HosterDead:
|
||||
return (origin, rola, sid, "martwe", "soft-404")
|
||||
except Exception as e:
|
||||
return (origin, rola, sid, "nieznane", type(e).__name__)
|
||||
if out:
|
||||
return (origin, rola, sid, "zywe", "")
|
||||
|
||||
# Brak ekstraktora dla tuba: zostaje sam status HTTP.
|
||||
if sitetag not in supported_sitetags():
|
||||
try:
|
||||
html = fetch_tube_html(url, timeout=_TIMEOUT, max_retries=1)
|
||||
except TubePageError as e:
|
||||
werdykt = "martwe" if e.status_code in (404, 410) else "nieznane"
|
||||
return (origin, rola, sid, werdykt, f"HTTP {e.status_code}")
|
||||
except Exception as e:
|
||||
return (origin, rola, sid, "nieznane", type(e).__name__)
|
||||
low = html[:200000].lower()
|
||||
hint = next((h for h in _SOFT404_HINTS if h in low), "")
|
||||
# HTTP 200 to dla nas ZAWSZE zywe. Fraza idzie wylacznie do raportu.
|
||||
return (origin, rola, sid, "zywe", f"soft404?={hint}" if hint else "")
|
||||
|
||||
return (origin, rola, sid, "nieznane", "ekstraktor zwrocil pusto")
|
||||
|
||||
|
||||
_DIGITS_RE = re.compile(r"\d{3,}")
|
||||
|
||||
|
||||
def _bogus(url: str) -> str | None:
|
||||
"""Prawdziwy adres -> adres sceny, ktora na pewno nie istnieje.
|
||||
|
||||
Podmieniamy OSTATNI dluzszy ciag cyfr (numer sceny) na absurdalnie wysoki, zamiast
|
||||
psuc slug. Powod: czesc tubow routuje po samym id i sluga w ogole nie sprawdza, wiec
|
||||
zepsuty slug oddalby 200 i falszywie wygladal na slepote sondy.
|
||||
"""
|
||||
m = list(_DIGITS_RE.finditer(url))
|
||||
if not m:
|
||||
return None
|
||||
last = m[-1]
|
||||
return url[: last.start()] + "99899989" + url[last.end() :]
|
||||
|
||||
|
||||
def _pick(origins: list[str], n_sample: int, n_control: int) -> list[tuple]:
|
||||
work: list[tuple] = []
|
||||
with session_scope() as s:
|
||||
for origin in origins:
|
||||
rows = s.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, page_url FROM playback_sources
|
||||
WHERE origin = :o AND dead_at IS NULL AND page_url IS NOT NULL
|
||||
ORDER BY random() LIMIT :n
|
||||
"""
|
||||
),
|
||||
{"o": origin, "n": n_sample},
|
||||
).fetchall()
|
||||
work += [(origin, "proba", sid, u) for sid, u in rows]
|
||||
|
||||
# Kontrola: zrodla scen, ktore naprawde odegraly sie z tego tuba.
|
||||
ctrl = s.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT ps.id, ps.page_url
|
||||
FROM playback_sources ps
|
||||
JOIN playback_events pe
|
||||
ON pe.scene_id = ps.scene_id AND pe.origin = ps.origin
|
||||
WHERE ps.origin = :o AND ps.dead_at IS NULL AND ps.page_url IS NOT NULL
|
||||
AND pe.status = 'success'
|
||||
AND pe.created_at > now() - interval '14 days'
|
||||
GROUP BY ps.id, ps.page_url
|
||||
ORDER BY random() LIMIT :n
|
||||
"""
|
||||
),
|
||||
{"o": origin, "n": n_control},
|
||||
).fetchall()
|
||||
work += [(origin, "kontrola", sid, u) for sid, u in ctrl]
|
||||
|
||||
# Kontrola negatywna: adresy, ktore NIE MOGA istniec. Bierzemy je z proby,
|
||||
# zeby miec dokladnie ten sam ksztalt URL co realne strony tego tuba.
|
||||
for sid, u in rows[:8]:
|
||||
bad = _bogus(u)
|
||||
if bad:
|
||||
work.append((origin, "negatyw", sid, bad))
|
||||
return work
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--sample", type=int, default=150)
|
||||
p.add_argument("--control", type=int, default=60)
|
||||
p.add_argument("--origins", default="", help="lista po przecinku (domyslnie wszystkie)")
|
||||
p.add_argument("--min-sources", type=int, default=500, help="pomin tuby mniejsze niz to")
|
||||
p.add_argument("--out", default="/tmp/_survey_out.json")
|
||||
a = p.parse_args(argv)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout
|
||||
)
|
||||
for noisy in ("httpx", "httpcore", "urllib3", "curl_cffi"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
if a.origins:
|
||||
origins = [o.strip() for o in a.origins.split(",") if o.strip()]
|
||||
else:
|
||||
with session_scope() as s:
|
||||
origins = [
|
||||
r[0]
|
||||
for r in s.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT origin FROM playback_sources WHERE dead_at IS NULL
|
||||
GROUP BY origin HAVING count(*) >= :m ORDER BY count(*) DESC
|
||||
"""
|
||||
),
|
||||
{"m": a.min_sources},
|
||||
).fetchall()
|
||||
]
|
||||
# Tuby resolvowane wylacznie w WebView na telefonie sa z VPS nieweryfikowalne
|
||||
# (CDN IP-bound / CAPTCHA), wiec kazda proba dalaby smieci zamiast pomiaru.
|
||||
slepe = [o for o in origins if is_vps_blocked_fallback(o.split(":", 1)[-1])]
|
||||
if slepe:
|
||||
log.info("pomijam (resolve tylko na telefonie): %s", ", ".join(slepe))
|
||||
origins = [o for o in origins if o not in slepe]
|
||||
log.info("tubow do zbadania: %d", len(origins))
|
||||
|
||||
work = _pick(origins, a.sample, a.control)
|
||||
# Przeplatamy tuby, zeby zaden host nie dostal calej swojej porcji ciagiem.
|
||||
random.shuffle(work)
|
||||
log.info("prob do wykonania: %d", len(work))
|
||||
|
||||
wynik: dict = collections.defaultdict(lambda: collections.defaultdict(collections.Counter))
|
||||
detale: dict = collections.defaultdict(collections.Counter)
|
||||
t0 = time.time()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
|
||||
for i, (origin, rola, _sid, werdykt, szczegol) in enumerate(pool.map(_probe, work), 1):
|
||||
wynik[origin][rola][werdykt] += 1
|
||||
if szczegol:
|
||||
detale[origin][f"{rola}:{szczegol}"[:60]] += 1
|
||||
if i % 250 == 0:
|
||||
log.info("[%d/%d] %.0f min", i, len(work), (time.time() - t0) / 60)
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
|
||||
log.info("=" * 100)
|
||||
log.info(
|
||||
"%-26s %6s %6s %6s %8s | %-14s %-12s %s",
|
||||
"tub", "zywe", "martwe", "nzn", "martwe%", "POZYTYWNA", "NEGATYWNA", "WERDYKT",
|
||||
)
|
||||
raport = {}
|
||||
for origin in sorted(wynik, key=lambda o: -wynik[o]["proba"]["martwe"]):
|
||||
pr = wynik[origin]["proba"]
|
||||
kt = wynik[origin]["kontrola"]
|
||||
ng = wynik[origin]["negatyw"]
|
||||
n, k, g = sum(pr.values()), sum(kt.values()), sum(ng.values())
|
||||
proc = 100 * pr["martwe"] / max(1, n)
|
||||
|
||||
# Pozytywna: scena, ktora sie odegrala, a teraz wraca martwa, to fals-pozytyw.
|
||||
# Nie zadamy zera, bo w 14 dni czesc filmow znika naprawde. Zadamy, zeby ten
|
||||
# szum byl niski, inaczej przemial kasowalby zywy katalog.
|
||||
poz_proc = 100 * kt["martwe"] / max(1, k)
|
||||
poz_ok = k >= 10 and poz_proc <= 5
|
||||
poz = f"{kt['martwe']}/{k}" + ("" if poz_ok else (" !!" if k >= 10 else " za malo"))
|
||||
|
||||
# Negatywna: adres, ktory nie moze istniec, MUSI wrocic martwy. Inaczej nie
|
||||
# odrozniamy "tub zdrowy" od "nic nie widzimy". Prog 80%, bo pojedynczy
|
||||
# wylosowany numer moze przypadkiem trafic w istniejaca scene.
|
||||
neg_ok = g >= 4 and ng["martwe"] >= 0.8 * g
|
||||
neg = f"{ng['martwe']}/{g}" + ("" if neg_ok else " SLEPA")
|
||||
|
||||
# Odsetek z proby minus szum z kontroli, czyli ile trupow naprawde spodziewamy sie
|
||||
# znalezc. Bez tej korekty raportowalibysmy wlasne fals-pozytywy jako martwe zrodla.
|
||||
netto = max(0.0, proc - poz_proc)
|
||||
|
||||
if not neg_ok:
|
||||
werdykt = "POMIN, sonda slepa"
|
||||
elif not poz_ok:
|
||||
werdykt = "POMIN, fals-pozytywy"
|
||||
elif netto < 2:
|
||||
werdykt = "zdrowy, nie ma co mielic"
|
||||
else:
|
||||
werdykt = f"MIELIC (~{netto:.0f}%)"
|
||||
|
||||
log.info(
|
||||
"%-26s %6d %6d %6d %7.0f%% | %-14s %-12s %s",
|
||||
origin, pr["zywe"], pr["martwe"], pr["nieznane"], proc, poz, neg, werdykt,
|
||||
)
|
||||
raport[origin] = {
|
||||
"proba": dict(pr),
|
||||
"kontrola": dict(kt),
|
||||
"negatyw": dict(ng),
|
||||
"martwe_proc": round(proc, 1),
|
||||
"martwe_netto_proc": round(netto, 1),
|
||||
"kontrola_ok": poz_ok,
|
||||
"negatyw_ok": neg_ok,
|
||||
"mielic": neg_ok and poz_ok and pr["martwe"] > 0,
|
||||
"detale": dict(detale[origin].most_common(5)),
|
||||
}
|
||||
|
||||
with open(a.out, "w", encoding="utf-8") as fh:
|
||||
json.dump(raport, fh, ensure_ascii=False, indent=1)
|
||||
log.info("zapisano %s | %.0f min", a.out, (time.time() - t0) / 60)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Reference in a new issue