Uogolnienie `sxyprn_reap_dead.py` na pozostale tuby. Werdykt bierze sie z `try_extract`, czyli z kodu, ktorego uzywa aplikacja przy odtwarzaniu, wiec sonda nie widzi czegos innego niz user. Martwe pada TYLKO na 404/410 albo `HosterDead`; pusty wynik ekstraktora to "nieznane" i zrodla nikt nie rusza. Bramka odpala sie ZA KAZDYM URUCHOMIENIEM, zamiast polegac na tym, ze ktos pamieta wynik wczesniejszego pomiaru. Sprawdza jednoczesnie, czy sonda widzi smierc (adresy scen, ktore nie moga istniec, musza wrocic martwe) i czy nie zabija zywych (sceny odegrane w ostatnich 7 dniach musza wrocic zywe). Nie zdana bramka konczy skrypt bez tkniecia bazy. Wynik pierwszego pomiaru: przemial ma sens dla hqporner (~29%) i galaxyporn (~14%). porntrex, xvideos, neporn, supjav i reszta soft-404 odpadaja, bo sonda ich smierci nie widzi. youperv odpada, bo oddaje martwe na 25% scen, ktore realnie sie odegraly. Trzy rzeczy, ktore wyszly dopiero przez kontrole, nie przez czytanie kodu: * Pierwsza wersja sondy pytala tylko o status HTTP i uznala SXYPRN za tuba, na ktorym nic nie widac (0/6), chociaz wlasnie wygasilismy tam 34% zrodel. Sxyprn oddaje skasowany post jako HTTP 200, a `HosterDead` leci dopiero z ekstraktora. Po przejsciu na `try_extract` negatywna daje 7/7. * Kontrola pozytywna przy 12 probach byla za ciasna: jeden trup to 8% i przewracal prog 5%, chociaz realny szum galaxyporn to ~3%. Stad 40 prob i okno 7 dni zamiast 14, bo im swiezsze odtworzenie, tym mocniej "martwe" wskazuje na nasz blad, a nie na kasowanie. * Budowanie adresow nieistniejacych przez podmiane cyfr nie dzialalo dla tubow BEZ numeru sceny w adresie (hentaigasm, mypornerleak, freshporno). Wychodzily jako "0/0", czyli niezmierzone, a to okolo 950 tys. zrodel. Teraz kolejno: numer w query, segment- identyfikator, a dopiero na koncu slug. Kolejnosc jest istotna, bo psucie segmentu trasy (`videos`) sprawdzaloby, czy tub obsluguje adres, a nie czy istnieje scena, wiec tub oddajacy 404 na zla trase i 200 na nieistniejaca scene falszywie zdalby bramke. Sonda siedzi w `_reap_probe.py` wspolnym dla pomiaru i przemialu, zeby bramka testowala dokladnie ten kod, ktory potem mieli baze.
279 lines
12 KiB
Python
279 lines
12 KiB
Python
"""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 sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
from sqlalchemy import text
|
|
|
|
from _reap_probe import bogus_url, probe
|
|
|
|
from app.db import session_scope
|
|
from app.extractors import is_vps_blocked_fallback, supported_sitetags
|
|
from app.extractors._fetch import fetch_tube_html
|
|
from app.extractors._models import 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). Cienka nakladka na wspolna sonde.
|
|
|
|
Werdykt liczy `_reap_probe.probe`, czyli DOKLADNIE ten sam kod, ktorego uzywa bramka
|
|
przemialu. Tutaj dokladamy tylko dwie rzeczy: obsluge tubow bez zarejestrowanego
|
|
ekstraktora (zostaje sam status HTTP) i wykrywanie fraz soft-404 do raportu.
|
|
"""
|
|
origin, rola, sid, url = item
|
|
sitetag = origin.split(":", 1)[-1]
|
|
werdykt, szczegol = probe(sitetag, url)
|
|
if werdykt != "unknown" or sitetag in supported_sitetags():
|
|
return (origin, rola, sid, {"alive": "zywe", "dead": "martwe"}.get(werdykt, "nieznane"), szczegol)
|
|
|
|
# Brak ekstraktora dla tuba: zostaje sam status HTTP.
|
|
try:
|
|
html = fetch_tube_html(url, timeout=_TIMEOUT, max_retries=1)
|
|
except TubePageError as e:
|
|
w = "martwe" if e.status_code in (404, 410) else "nieznane"
|
|
return (origin, rola, sid, w, 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 "")
|
|
|
|
|
|
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_url(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())
|