feat(tags): zdejmij znaczniki jakosci + przytnij ogon tagow rule34video

Znaczniki jakosci (hd, 4k, 720p, HD Videos, wzorzec ^\d{3,4}p$) trafiaja na stoplist
i znikaja z katalogu: 323 379 przypisan, 16 tagow. Powod: apka ma osobny filtr jakosci
liczony ze ZRODEL ODTWARZANIA, ktory jest wiarygodny, a tag jest deklaracja tuba i bywa
falszywy. Dwa rownolegle sygnaly o tym samym, z ktorych jeden klamie, sa gorsze niz jeden.

Ogon rule34video: 356 scen wnosilo 1112 roznych tagow do WSPOLNEJ listy uzywanej przez
caly katalog, z czego 602 wystepowaly dokladnie raz (nazwy postaci i serii, tagowanie
w stylu booru). Nowy job co 24h zostawia tagi z >=3 scen i najwyzej 15 na scene,
uporzadkowane po czestosci w obrebie serwisu.

Wazny szczegol reguly, wychwycony dopiero w symulacji: ochrona tagow wspolnych dotyczy
TYLKO progu, nie limitu. Pierwsza wersja wylaczala tagi wspolne rowniez z limitu i przez
to prawie nie dzialala, rekord spadal 161 -> 147 zamiast do 15, bo najbardziej obsypana
scena skladala sie glownie z tagow wspolnych.

Wynik na produkcji: srednia na scenie rule34video 13,5 -> 9,1, rekord 161 -> 15, zadna
scena nie zostala bez tagow. Usuniete 1554 przypisania i 2475 osieroconych tagow.
VACUUM ANALYZE po obu operacjach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-08-14 12:28:47 +02:00
parent 38a2724963
commit 8c8f22d388
5 changed files with 175 additions and 7 deletions

View file

@ -150,6 +150,11 @@ class Settings(BaseSettings):
sched_junk_performers_hours: int = Field(
default=24, validation_alias="GOON_SCHED_JUNK_PERFORMERS_HOURS"
)
# Przycinanie ogona tagow rule34video (tagowanie booru: nazwy postaci/serii).
# 356 scen wnosilo 1112 tagow do WSPOLNEJ listy, 602 z nich uzyte raz. 24h. 0 = off.
sched_tag_tail_trim_hours: int = Field(
default=24, validation_alias="GOON_SCHED_TAG_TAIL_TRIM_HOURS"
)
# Ingest freshness watchdog, alert do Sentry gdy aktywny tube (origin
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie

View file

@ -15,15 +15,18 @@ Kryterium wpisu: tag nie zawezaja wyszukiwania, bo pasuje do niemal wszystkiego
w katalogu ("porn", "sex", "hot"), albo opisuje sposob dystrybucji, a nie tresc
("premium", "exclusive", "leaked", "download").
Swiadomie NIE na liscie:
- `onlyfans` (71 972 przypisan) - to realny typ tresci, ludzie tego szukaja.
- `4k`, `hd`, `720p`, `1080p` (ok. 110 tys.) - niosa jakas informacje, choc
niepewna. Apka ma osobny, wiarygodny filtr jakosci liczony ze zrodel odtwarzania,
wiec te tagi sa zbedne, ale usuniecie ich to decyzja produktowa, nie sprzatanie
smieci. Do przegadania osobno.
Znaczniki jakosci (`hd`, `4k`, `720p`, `HD Videos`) tez sa na liscie, decyzja z 2026-08-14.
Powod: apka ma osobny filtr jakosci liczony ze ZRODEL ODTWARZANIA, ktory jest wiarygodny,
a tag jest deklaracja tuba i bywa falszywy. Dwa rownolegle sygnaly o tym samym, z ktorych
jeden klamie, sa gorsze niz jeden. Samo `HD Videos` mialo 205 488 przypisan.
Swiadomie NIE na liscie: `onlyfans` (71 972 przypisan). To realny typ tresci, nie chwyt
marketingowy, ludzie tego szukaja.
"""
from __future__ import annotations
import re
#: Porownanie po slugu (lowercase, dywizy), bo ta sama rzecz przychodzi jako
#: "Full HD", "full-hd" i "FullHD".
JUNK_TAG_SLUGS: frozenset[str] = frozenset(
@ -65,10 +68,30 @@ JUNK_TAG_SLUGS: frozenset[str] = frozenset(
"streaming",
"online",
"full-video",
# znaczniki jakosci, patrz docstring: dubluja filtr liczony ze zrodel i klamia
"hd",
"hd-porn",
"hd-videos",
"sd",
"fhd",
"uhd",
"full-hd",
"fullhd",
"ultra-hd",
"high-definition",
"hi-res",
"4k",
"8k",
"4k-porn",
}
)
#: Rozdzielczosc jako tag: 240p, 480p, 720p, 1080p, 2160p... Wzorzec zamiast wyliczanki,
#: bo tuby wymyslaja kolejne warianty przy kazdym re-scrape.
_RESOLUTION_RE = re.compile(r"^\d{3,4}p$")
def is_junk_tag(slug: str) -> bool:
"""True dla tagu, ktorego nie chcemy trzymac w katalogu."""
return slug.strip().lower() in JUNK_TAG_SLUGS
s = slug.strip().lower()
return s in JUNK_TAG_SLUGS or bool(_RESOLUTION_RE.match(s))

View file

@ -308,6 +308,21 @@ def _job_phash_blacklist() -> None:
log.exception("[scheduler] phash blacklist failed")
def _job_tag_tail_trim() -> None:
"""Przycina ogon tagow rule34video (booru: nazwy postaci, serii, autorow).
356 scen wnosilo 1112 roznych tagow do wspolnej listy, 602 uzyte dokladnie raz.
Zostawia tagi z >=3 scen i najwyzej 15 na scene; tagi uzywane takze poza
rule34video sa chronione, bo naleza do wspolnego slownika."""
log.info("[scheduler] tag tail trim starting")
try:
from app.scheduler.tag_tail_trim import run
stats = run()
log.info("[scheduler] tag tail trim done: %s", stats)
except Exception:
log.exception("[scheduler] tag tail trim failed")
def _job_junk_performers() -> None:
"""Kasuje kategorie i studia podszywające się pod performerów („Creampie",
„Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
@ -550,6 +565,17 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
cfg["phash_backfill_minutes"], _batch,
)
if cfg.get("tag_tail_trim_hours"):
sched.add_job(
_job_tag_tail_trim,
IntervalTrigger(hours=cfg["tag_tail_trim_hours"], start_date=INTERVAL_ANCHOR),
id="tag_tail_trim",
replace_existing=True,
max_instances=1,
coalesce=True,
)
log.info("scheduler: tag tail trim every %dh", cfg["tag_tail_trim_hours"])
if cfg.get("junk_performers_hours"):
sched.add_job(
_job_junk_performers,

View file

@ -0,0 +1,112 @@
"""Przycinanie ogona tagow z rule34video (tagowanie w stylu booru).
Skad problem: rule34video taguje jak booru, czyli nazwami postaci, serii i autorow.
Zmierzone 2026-08-14: **356 scen ma 1112 roznych tagow**, srednio 13,5 na scene, rekord
161 (zgloszenie uzytkownika o scenie "TEMPTATION - Episode 14"). Z tych 1112 tagow
**602 wystepuja dokladnie raz**, a 763 najwyzej dwa razy. To sa nazwy wlasne.
Same w sobie nie sa bledne, ludzie ich szukaja, ale 356 scen wrzuca ponad tysiac pozycji
do WSPOLNEJ listy tagow, ktorej uzywa caly katalog (1,9 mln scen). Stad przyciecie,
a nie skasowanie zrodla.
Dwie reguly, obie potrzebne (zmierzone osobno):
1. **Prog czestosci**: tag musi wystepowac na >=3 scenach rule34video. Sam prog zbija
srednia 13,5 -> 10,9 i rekord 161 -> 104, wiec NIE wystarcza.
2. **Limit na scene**: najwyzej 15 tagow, zostawiamy najczestsze w obrebie serwisu.
Dopiero to domyka rekord.
Ochrona tagow wspolnych dotyczy TYLKO progu, nie limitu, i to jest istotne. Tag uzywany
takze poza rule34video (np. "Bondage") nalezy do wspolnego slownika, wiec nie kasujemy go
z powodu niskiej czestosci w obrebie serwisu. Ale limit na scene obejmuje go normalnie:
scena nie powinna miec 161 tagow niezaleznie od tego, czyje one sa.
Pierwsza wersja tej reguly wylaczala tagi wspolne takze z limitu i przez to prawie nie
dzialala: rekord spadal 161 -> 147 zamiast do 15, bo najbardziej obsypana scena skladala
sie glownie z tagow wspolnych.
Uwaga o zbieznosci: limit na scene obniza liczniki tagow, wiec kolejny przebieg moze
zejsc nieco nizej, zanim sie ustabilizuje. Liczniki bierzemy ze SNAPSHOTU sprzed zmian
w danym przebiegu, zeby jeden przebieg nie kaskadowal sam na sobie.
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from app.db import session_scope
log = logging.getLogger(__name__)
ORIGIN = "tube:rule34video"
MIN_SCENES_PER_TAG = 3
MAX_TAGS_PER_SCENE = 15
_SQL = text(
"""
WITH r34 AS (
SELECT DISTINCT p.scene_id AS id
FROM playback_sources p
WHERE p.origin = :origin
),
-- snapshot licznikow SPRZED zmian w tym przebiegu
freq AS (
SELECT st.tag_id, count(*) AS c
FROM scene_tags st JOIN r34 ON r34.id = st.scene_id
GROUP BY st.tag_id
),
-- tag uzywany takze poza rule34video nalezy do wspolnego slownika, nie ruszamy
shared AS (
SELECT DISTINCT st.tag_id
FROM scene_tags st
WHERE st.tag_id IN (SELECT tag_id FROM freq)
AND st.scene_id NOT IN (SELECT id FROM r34)
),
ranked AS (
SELECT st.scene_id, st.tag_id, f.c AS cnt,
(st.tag_id IN (SELECT tag_id FROM shared)) AS is_shared,
row_number() OVER (PARTITION BY st.scene_id ORDER BY f.c DESC, st.tag_id) AS rn
FROM scene_tags st
JOIN r34 ON r34.id = st.scene_id
JOIN freq f ON f.tag_id = st.tag_id
),
doomed AS (
SELECT scene_id, tag_id FROM ranked
-- limit obowiazuje wszystkie tagi; prog tylko te wylacznie rule34video
WHERE rn > :max_tags OR (cnt < :min_scenes AND NOT is_shared)
)
DELETE FROM scene_tags st
USING doomed d
WHERE st.scene_id = d.scene_id AND st.tag_id = d.tag_id
"""
)
# Tagi, ktore po przycieciu nie wisza juz nigdzie. Zostawione zasmiecalyby liste tagow
# mimo zerowej liczby scen.
_SQL_ORPHANS = text(
"""
DELETE FROM tags t
WHERE NOT EXISTS (SELECT 1 FROM scene_tags st WHERE st.tag_id = t.id)
AND NOT EXISTS (SELECT 1 FROM movie_tags mt WHERE mt.tag_id = t.id)
AND NOT EXISTS (SELECT 1 FROM blacklisted_tags bt WHERE bt.tag_id = t.id)
"""
)
def run() -> dict[str, int]:
"""Przytnij ogon i skasuj osierocone tagi. Zwraca licznik zmian."""
with session_scope() as session:
removed = session.execute(
_SQL,
{
"origin": ORIGIN,
"min_scenes": MIN_SCENES_PER_TAG,
"max_tags": MAX_TAGS_PER_SCENE,
},
).rowcount
orphans = session.execute(_SQL_ORPHANS).rowcount
session.commit()
log.info(
"tag_tail_trim: usunieto %s przypisan, %s osieroconych tagow", removed, orphans
)
return {"removed": removed or 0, "orphans": orphans or 0}

View file

@ -224,6 +224,8 @@ def run_forever() -> int:
# Śmieciowi performerzy, kategorie/studia udające osoby (audyt 2026-07-27).
# Kryterium wąskie celowo: szerokie kasuje realnych ludzi bez refa w TPDB.
"junk_performers_hours": getattr(settings, "sched_junk_performers_hours", 24) or None,
# Ogon tagow rule34video (nazwy wlasne w stylu booru zalewajace liste tagow).
"tag_tail_trim_hours": getattr(settings, "sched_tag_tail_trim_hours", 24) or None,
# Taxonomy scene_count refresh, denormalizacja liczników (perf fix 0019).
"taxonomy_counts_hours": getattr(settings, "sched_taxonomy_counts_hours", 3) or None,
# Ingest freshness watchdog, per-origin alert do Sentry (report 14f3a655).