goon/scripts/sxyprn_purge_uploaders.py

263 lines
11 KiB
Python

"""Usuwa z bazy sceny zablokowanych uploaderow sxyprn i sciaga smieciowych performerow.
Kontekst (2026-09-04): trzej autorzy (Shizuka, Ajx, Denzel) wrzucaja dobre sceny, ale z
obsada wpisana w tytul i 25-35 hashtagami. Tytul z lista nazwisk to over-attribution
performerow, hashtagi to 27 tagow na scene zamiast 12. Scraper ma juz ich na blokadzie
(`_BLOCKED_AUTHORS` w `app/connectors/direct_scrapers/sxyprn.py`), ten skrypt sprzata
to, co juz weszlo.
Autora poznajemy po sciezce miniatury: `.../<id_autora>/<id_posta>/small.jpg`.
Co robi:
1. Sceny, ktore maja TYLKO zrodla tych autorow (zadnego innego zywego zrodla, zadnego
refu tpdb/stashdb): DELETE ze scenes (kaskada zdejmuje refy, performerow, tagi,
ulubione, postep). To jest utrata danych userow, dlatego domyslnie dry-run.
2. Sceny scalone z innymi zrodlami: zostaja, wylatuje tylko zrodlo sxyprn tego autora
i tag „Sizuka" (podpis Shizuki).
3. Smieciowi performerzy: na scenach, ktorych jedynym zrodlem jest sxyprn, zdejmuje
przypisania jednowyrazowych performerow, ktorych nazwa nie stoi doslownie w tytule
(ani jako zlepek „JoannaAngel" przy tytule „Joanna Angel").
To pozostalosc po dopasowaniu przedrostkiem w obie strony („Thebdck" do „the"),
naprawionym tego samego dnia w `person_named_in`.
Uzycie (na VPS, z pliku, nie przez stdin):
docker compose run --rm worker python3 -u /srv/scripts/sxyprn_purge_uploaders.py
docker compose run --rm worker python3 -u /srv/scripts/sxyprn_purge_uploaders.py --apply
docker compose run --rm worker python3 -u /srv/scripts/sxyprn_purge_uploaders.py --apply --strip
Wariant --strip (wybrany 2026-09-04): sceny zostaja z playbackiem, ulubionymi i historia,
wylatuja tylko tagi i performerzy spoza tytulu.
"""
from __future__ import annotations
import argparse
import sys
from sqlalchemy import text
from app.db import session_scope
AUTORZY = {
"69cab3467ec41": "Shizuka",
"68e92925d23d9": "Ajx",
"6a0d125a875d2": "Denzel",
}
_CHUNK = 500
# Zrodlo sxyprn z id autora wyciagnietym z miniatury (drugi segment musi byc id posta).
_SQL_ZRODLA_AUTOROW = text(
"""
SELECT ps.id, ps.scene_id, a.aid
FROM playback_sources ps
CROSS JOIN LATERAL (
SELECT substring(
ps.thumbnail_url FROM
'/([a-f0-9]{13})/' || substring(ps.page_url FROM '/post/([a-f0-9]+)\\.html') || '/'
) AS aid
) a
WHERE ps.origin = 'tube:sxyprncom' AND a.aid = ANY(:autorzy)
"""
)
_SQL_KLASYFIKACJA = text(
"""
SELECT s.id,
EXISTS (SELECT 1 FROM playback_sources p WHERE p.scene_id = s.id
AND p.dead_at IS NULL AND p.id <> ALL(:zrodla_autorow)) AS inne_zrodlo,
EXISTS (SELECT 1 FROM scene_external_refs r JOIN sources so ON so.id = r.source_id
WHERE r.scene_id = s.id AND so.name IN ('tpdb', 'stashdb')) AS kanon,
(SELECT count(*) FROM favorite_scenes f WHERE f.scene_id = s.id) AS ulubione,
(SELECT count(*) FROM scene_play_progress f WHERE f.scene_id = s.id) AS ogladane
FROM scenes s WHERE s.id = ANY(:sceny)
"""
)
_SQL_SMIECIOWI_PERF = text(
"""
WITH only_sx AS (
SELECT s.id, s.title FROM scenes s
WHERE EXISTS (SELECT 1 FROM playback_sources p WHERE p.scene_id = s.id AND p.origin = 'tube:sxyprncom')
AND NOT EXISTS (SELECT 1 FROM playback_sources p WHERE p.scene_id = s.id AND p.origin <> 'tube:sxyprncom')
AND NOT EXISTS (SELECT 1 FROM scene_external_refs r JOIN sources so ON so.id = r.source_id
WHERE r.scene_id = s.id AND so.name IN ('tpdb', 'stashdb'))
)
SELECT sp.scene_id, sp.performer_id, p.canonical_name
FROM only_sx o
JOIN scene_performers sp ON sp.scene_id = o.id
JOIN performers p ON p.id = sp.performer_id
WHERE p.canonical_name !~ '\\s'
AND position(lower(p.canonical_name) IN lower(o.title)) = 0
AND NOT (length(p.canonical_name) >= 5
AND position(regexp_replace(lower(p.canonical_name), '[^a-z0-9]', '', 'g')
IN regexp_replace(lower(o.title), '[^a-z0-9]', '', 'g')) > 0)
"""
)
def _chunks(seq: list, n: int):
for i in range(0, len(seq), n):
yield seq[i : i + n]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="bez tego tylko raport")
ap.add_argument("--skip-junk", action="store_true", help="pomin krok 3 (smieciowi performerzy)")
ap.add_argument("--only-junk", action="store_true", help="tylko krok 3, scen i zrodel nie ruszaj")
ap.add_argument(
"--strip",
action="store_true",
help="zamiast kasowac sceny: zdejmij im wszystkie tagi i performerow spoza tytulu "
"(decyzja 2026-09-04, zeby nie tracic ulubionych i historii userow)",
)
ap.add_argument("--authors", default=",".join(AUTORZY), help="id autorow po przecinku")
args = ap.parse_args()
autorzy = [a.strip().lower() for a in args.authors.split(",") if a.strip()]
dry = not args.apply
print(f"tryb: {'DRY-RUN' if dry else 'APPLY'}; autorzy: {', '.join(AUTORZY.get(a, a) for a in autorzy)}")
with session_scope() as s:
rows = s.execute(_SQL_ZRODLA_AUTOROW, {"autorzy": autorzy}).all()
zrodla = [r[0] for r in rows]
per_autor: dict[str, set] = {}
sceny_all: set = set()
for _, scene_id, aid in rows:
per_autor.setdefault(aid, set()).add(scene_id)
sceny_all.add(scene_id)
for aid, ids in per_autor.items():
n_src = sum(1 for r in rows if r[2] == aid)
print(f" {AUTORZY.get(aid, aid):<8} zrodel/scen: {n_src}/{len(ids)}")
sceny = sorted(sceny_all, key=str)
print(f"lacznie: {len(zrodla)} zrodel, {len(sceny)} scen")
do_usuniecia: list = []
do_odciecia: list = []
ulub = ogl = 0
for chunk in _chunks(sceny, _CHUNK):
for sid, inne, kanon, u, o in s.execute(
_SQL_KLASYFIKACJA, {"sceny": chunk, "zrodla_autorow": zrodla}
).all():
if inne or kanon:
do_odciecia.append(sid)
else:
do_usuniecia.append(sid)
ulub += u
ogl += o
print(
f"do usuniecia: {len(do_usuniecia)} scen (ulubionych {ulub}, wpisow historii {ogl}); "
f"do odciecia zrodla (scena zostaje): {len(do_odciecia)}"
)
smieci = [] if args.skip_junk else s.execute(_SQL_SMIECIOWI_PERF).all()
if not args.skip_junk:
print(
f"smieciowe przypisania performerow na scenach tylko-sxyprn: {len(smieci)} "
f"na {len({r[0] for r in smieci})} scenach"
)
top: dict[str, int] = {}
for _, _, name in smieci:
top[name] = top.get(name, 0) + 1
najczestsi = sorted(top.items(), key=lambda x: -x[1])[:12]
print(" najczestsi: " + ", ".join(f"{n} ({c})" for n, c in najczestsi))
if dry:
print("dry-run, nic nie zmieniam")
return 0
if args.only_junk:
do_odciecia, do_usuniecia = [], []
print("tylko krok 3, sceny i zrodla zostaja")
if args.strip:
# Sceny wylacznie tych autorow: zostaja razem ze zrodlem, ale bez tagow
# (wszystkie sa z hashtagow spamera) i bez performerow, ktorych nie ma w tytule.
n_tag = n_perf = 0
for chunk in _chunks(do_usuniecia, _CHUNK):
n_tag += s.execute(
text("DELETE FROM scene_tags WHERE scene_id = ANY(:sceny)"), {"sceny": chunk}
).rowcount
n_perf += s.execute(
text(
"DELETE FROM scene_performers sp USING scenes sc, performers p "
"WHERE sc.id = sp.scene_id AND p.id = sp.performer_id AND sp.scene_id = ANY(:sceny) "
"AND position(lower(p.canonical_name) IN lower(sc.title)) = 0 "
"AND position(regexp_replace(lower(p.canonical_name), '[^a-z0-9]', '', 'g') "
"IN regexp_replace(lower(sc.title), '[^a-z0-9]', '', 'g')) = 0"
),
{"sceny": chunk},
).rowcount
s.commit()
# Sceny scalone z innymi tubami: tagow nie da sie przypisac do autora, wiec
# tylko podpis Shizuki.
n_siz = 0
for chunk in _chunks(do_odciecia, _CHUNK):
n_siz += s.execute(
text(
"DELETE FROM scene_tags st USING tags t "
"WHERE t.id = st.tag_id AND t.slug = 'sizuka' AND st.scene_id = ANY(:sceny)"
),
{"sceny": chunk},
).rowcount
s.commit()
print(
f"strip: scen {len(do_usuniecia)}, zdjete tagi {n_tag}, zdjeci performerzy {n_perf}; "
f"scalone {len(do_odciecia)}, zdjety tag sizuka {n_siz}"
)
do_odciecia, do_usuniecia = [], []
# 2. sceny scalone: zostaja, wylatuje tylko zrodlo autora + tag Sizuka
n_ps = 0
for chunk in _chunks(do_odciecia, _CHUNK):
n_ps += s.execute(
text("DELETE FROM playback_sources WHERE scene_id = ANY(:sceny) AND id = ANY(:zrodla)"),
{"sceny": chunk, "zrodla": zrodla},
).rowcount
s.execute(
text(
"DELETE FROM scene_tags st USING tags t "
"WHERE t.id = st.tag_id AND t.slug = 'sizuka' AND st.scene_id = ANY(:sceny)"
),
{"sceny": chunk},
)
s.commit()
print(f"odciete zrodla: {n_ps}")
# 1. sceny wylacznie tych autorow: DELETE, kaskada robi reszte
n_sc = 0
for chunk in _chunks(do_usuniecia, _CHUNK):
s.execute(
text("DELETE FROM merge_candidates WHERE left_id = ANY(:sceny) OR right_id = ANY(:sceny)"),
{"sceny": chunk},
)
n_sc += s.execute(text("DELETE FROM scenes WHERE id = ANY(:sceny)"), {"sceny": chunk}).rowcount
s.commit()
print(f"usuniete sceny: {n_sc}")
# 3. smieciowi performerzy
n_sp = 0
pary = [(r[0], r[1]) for r in smieci]
for chunk in _chunks(pary, _CHUNK):
n_sp += s.execute(
text(
"DELETE FROM scene_performers sp "
"USING unnest(CAST(:sc AS uuid[]), CAST(:pf AS uuid[])) AS u(sc, pf) "
"WHERE sp.scene_id = u.sc AND sp.performer_id = u.pf"
),
{"sc": [c[0] for c in chunk], "pf": [c[1] for c in chunk]},
).rowcount
s.commit()
print(f"zdjete smieciowe przypisania: {n_sp}")
# podpis Shizuki jako tag: bez scen nie ma czego trzymac
s.execute(
text(
"DELETE FROM tags t WHERE t.slug = 'sizuka' "
"AND NOT EXISTS (SELECT 1 FROM scene_tags st WHERE st.tag_id = t.id)"
)
)
s.commit()
return 0
if __name__ == "__main__":
sys.exit(main())