fix(search): nazwa performera musi byc w slugu SPOJNA FRAZA

Zgloszenie 2026-08-13: strona Tiffani Time pelna scen o Tiffany Tatum,
Tiffani Madison i Victorii Tiffani.

Przyczyna: `_search_base.search` dokleja szukanego performera do KAZDEJ
sceny bezwarunkowo (`all_performers = [RawPerformer(name=query), ...]`),
wiec cala poprawnosc obsady wisi na filtrze tokenow. A filtr testowal
PODCIAG na luznych tokenach, wiec:

  "tiffany" ⊂ "lp-s-tiffany-love-first-time-gonzo"   (inna osoba)
  "time"    ⊂ "adult-time", "first-time", "multiple-times"  (pospolite slowo)

Historia tej linijki to trzy poziomy: any() -> all() -> (teraz) fraza.
`all()` naprawil "Ava Koxxx na scenach Avy Devine", ale zostawil oba
powyzsze. Teraz slug musi zawierac CALA nazwe jako ciag, z granica slowa
po bokach, z dowolnym separatorem miedzy czlonami.

Dozwolone koncowe "s": slug gubi apostrof, wiec "TiffaniTime's" zapisuje
jako "tiffanitimes". Nie luzuje bramki, bo przed tym "s" stoi cala fraza.

hqporner mial WLASNA kopie tego filtru (i wlasny zestaw dziur) - podpiety
pod ta sama funkcje, zeby sie nie rozjezdzalo.

12 przypadkow testowych z realnych danych, w tym te ktore MUSZA przejsc
(tiffani-time-*, tiffanitimes-*, mia-li-* z czlonem <3 znaki).

Do tego skrypt czyszczacy juz istniejace pomylki. Kryterium zawezone po
obejrzeniu probki: samo "brak nazwy w tytule" kasowalo MESKICH performerow
(tuby nie wypisuja ich w tytule, a przypisanie szlo z listy obsady, nie
z searcha). Dolozony warunek "inny performer sceny ma dowod I dzieli
czlon" - probka 14/14 to realne pomylki.
This commit is contained in:
goon-foss 2026-08-13 11:17:43 +02:00
parent a16883d2f3
commit 5217d07222
3 changed files with 176 additions and 10 deletions

View file

@ -120,6 +120,28 @@ class BaseSearchScraper(BaseDirectTubeScraper):
"""
return None
@staticmethod
def _slug_matches_query(slug_lower: str, query: str) -> bool:
"""Czy slug zawiera nazwę z query jako spójną frazę, z granicami słowa.
Człony rozdzielamy dowolnym niealfanumerycznym separatorem (tube używa `-`,
`_` albo skleja), a po bokach wymagamy granicy, żeby „tiffany" nie łapało się
w „stiffany". Człony krótsze niż `_query_token_min_len` pomijamy przy budowie
frazy (np. „Mia Li"), inaczej wypadłyby wszystkie sceny tej osoby.
"""
toks = [
re.escape(tok)
for tok in query.lower().split()
if len(tok) >= BaseSearchScraper._query_token_min_len
]
if not toks:
return True
phrase = r"[^a-z0-9]*".join(toks)
# Dozwolone końcowe „s": slug gubi apostrof, więc dzierżawcze „TiffaniTime's"
# zapisuje jako „tiffanitimes". Nie luzuje to bramki, bo przed tym „s" i tak
# musi stać cała fraza (samo „first-times" nadal nie przejdzie dla „Tiffani Time").
return re.search(rf"(?<![a-z0-9]){phrase}s?(?![a-z0-9])", slug_lower) is not None
def search(
self,
query: str,
@ -164,14 +186,23 @@ class BaseSearchScraper(BaseDirectTubeScraper):
slug_lower = slug.lower()
if slug_lower in self._nav_slug_blacklist:
continue
# Strict: WSZYSTKIE query tokens muszą być w slug. Wcześniej `any()`
# przepuszczał scenę gdy choć jeden token był w slug, dla performera
# "Ava Koxxx" (query="ava koxxx") wszystkie sceny z "ava-*" slug
# (Ava Devine, Ava Addams itp.) były labelowane jako "Ava Koxxx",
# bo `any("ava" in slug)` =True. User reports: scena "ava devine
# gangbanged..." miała Ava Koxxx w DB. Fix: `all()`, slug musi
# zawierać każdy ≥3-char token z imienia performera.
if query_tokens and not all(tok in slug_lower for tok in query_tokens):
# Slug musi zawierać CAŁE nazwisko jako SPÓJNĄ FRAZĘ, nie luźno rozrzucone
# tokeny. Historia tej linijki to trzy poziomy:
#
# 1. `any(tok in slug)` — scena z „ava-devine" dostawała Ava Koxxx.
# 2. `all(tok in slug)` — lepiej, ale to wciąż test PODCIĄGU na luźnych
# tokenach, więc przy „Tiffani Time" przechodziło wszystko, co miało
# gdziekolwiek „time" (first-time, adult-time, multiple-times) plus
# cokolwiek zawierającego „tiffani". Przy „Tiffany Time" dochodziło
# jeszcze „tiffany" ⊂ „s-tiffany-love". Zgłoszenie 2026-08-13: strona
# Tiffani Time pełna scen o Tiffany Tatum, Tiffani Madison i Victorii.
# 3. (tu) fraza: „tiffani-time" musi wystąpić w slugu jako ciąg, z dowolnym
# separatorem między członami i granicą słowa po bokach.
#
# To ma znaczenie, bo `all_performers` niżej dokleja szukanego performera
# BEZWARUNKOWO do każdej sceny, która tu przejdzie. Ten warunek jest jedyną
# bramką między wyszukiwarką tuba (dopasowuje rozmyto) a obsadą w bazie.
if query_tokens and not self._slug_matches_query(slug_lower, query):
continue
title = self._title_from_slug(slug)

View file

@ -17,6 +17,7 @@ import urllib.parse
from collections.abc import Iterator
from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene
from app.connectors.direct_scrapers._search_base import BaseSearchScraper
from app.connectors.direct_scrapers.base import BaseDirectTubeScraper
from app.extractors import browser_get
@ -73,9 +74,13 @@ class HQPornerScraper(BaseDirectTubeScraper):
continue
seen_urls.add(scene_url)
# Title-token filter
# Nazwa musi być w slugu SPÓJNĄ FRAZĄ, nie luźnymi tokenami. Wspólna
# implementacja z `BaseSearchScraper`, żeby ta bramka nie rozjeżdżała się
# między scraperami (tu była własna kopia i przez to własny zestaw dziur:
# `all(tok in slug)` przepuszczał „tiffany" w „s-tiffany-love" i „time"
# w „first-time" — zgłoszenie 2026-08-13, strona Tiffani Time).
slug_lower = slug_part.lower()
if query_tokens and not all(tok in slug_lower for tok in query_tokens):
if query_tokens and not BaseSearchScraper._slug_matches_query(slug_lower, query):
continue
if _needs_compact and _compact not in re.sub(r"[^a-z0-9]", "", slug_lower):
continue

View file

@ -0,0 +1,130 @@
"""Kasuje przypisania performerów powstałe przez CZĘŚCIOWE dopasowanie nazwy.
Mechanizm, który to produkował (naprawiony 2026-08-13 w `_search_base._slug_matches_query`):
search po nazwie doklejał szukanego performera do KAŻDEJ sceny, która przeszła filtr
tokenów, a filtr testował PODCIĄG na luźnych tokenach. Więc „Tiffany Time" łapało się
w „s-tiffany-love-first-time", a „Eliza E" w „eliza-ibarra". Zgłoszenie 2026-08-13:
strona Tiffani Time pełna scen o Tiffany Tatum, Tiffani Madison i Victorii Tiffani.
**Kryterium (trzy warunki naraz, każdy konieczny):**
1. przypisanie pochodzi ze scrapera (kanon nietykalny, `scene_performers.source_id`),
2. nazwy performera NIE ma w tytule ani URL-u sceny (brak dowodu),
3. na tej samej scenie jest INNY performer, który nazwę ma **i dzieli z nim człon**.
Trzeci warunek jest tym, co odróżnia pomyłkę od braku danych, i został dołożony po
obejrzeniu próbki. Bez niego reguła kasowała **męskich performerów**: tuby zwyczajowo
nie wypisują ich w tytule („Charles Dera" przy scenie „HouseHumpers – Demi Hawks and
Serena Hill"), a przypisanie przyszło z listy obsady, nie z searcha, więc było poprawne.
Po zawężeniu próbka 14/14 to realne pomyłki: Nikki Nevada przy Nikki Brooks, Eliza E
przy Elizie Ibarze, Miss Marisa przy Miss Fufu, CJ Sloan przy Sloan Rider.
Wsadowo po zakresach `scene_id`, bo pojedynczy DELETE po całej tabeli trzymałby locki
na `scene_performers` i zablokował ingest (zdarzyło się przy migracji 0026).
Uruchomienie (kontener worker):
python -m scripts.prune_token_overlap_attributions # dry-run
python -m scripts.prune_token_overlap_attributions --yes # wykonaj
"""
from __future__ import annotations
import argparse
import logging
from sqlalchemy import text
from app.db import session_scope
log = logging.getLogger(__name__)
#: 16 przedziałów po pierwszym nibblu UUID-a. Każdy to osobna transakcja.
_PREFIXES = "0123456789abcdef"
_BUILD_SQL = """
CREATE TEMP TABLE ev ON COMMIT DROP AS
SELECT sp.scene_id,
sp.performer_id,
(replace(replace(replace(
lower(coalesce(ps.page_url,'') || ' ' || s.title), '-',''), '_',''), ' ','')
LIKE '%' || replace(lower(p.canonical_name),' ','') || '%') AS ma_dowod,
ARRAY(
SELECT t FROM unnest(string_to_array(lower(p.canonical_name),' ')) t
WHERE length(t) >= 3
) AS tokeny
FROM scene_performers sp
JOIN sources src ON src.id = sp.source_id AND src.name = 'tube-scraper'
JOIN performers p ON p.id = sp.performer_id
JOIN scenes s ON s.id = sp.scene_id
LEFT JOIN LATERAL (
SELECT page_url FROM playback_sources x WHERE x.scene_id = s.id LIMIT 1
) ps ON true
WHERE sp.scene_id >= CAST(:lo AS uuid) AND sp.scene_id < CAST(:hi AS uuid);
CREATE INDEX ON ev (scene_id);
ANALYZE ev;
"""
#: Kandydaci: bez dowodu, ale inny performer TEJ SCENY dowód ma i dzieli człon.
_SELECT_SQL = """
SELECT e.scene_id, e.performer_id
FROM ev e
WHERE NOT e.ma_dowod
AND EXISTS (
SELECT 1 FROM ev e2
WHERE e2.scene_id = e.scene_id
AND e2.performer_id <> e.performer_id
AND e2.ma_dowod
AND e2.tokeny && e.tokeny
)
"""
def _range(prefix: str) -> tuple[str, str]:
lo = prefix + "0000000-0000-0000-0000-000000000000"
nxt = _PREFIXES[_PREFIXES.index(prefix) + 1] if prefix != "f" else None
hi = (nxt + "0000000-0000-0000-0000-000000000000") if nxt else "ffffffff-ffff-ffff-ffff-ffffffffffff"
return lo, hi
def run(*, apply: bool) -> dict[str, int]:
total = 0
for prefix in _PREFIXES:
lo, hi = _range(prefix)
with session_scope() as session:
for stmt in _BUILD_SQL.strip().split(";\n"):
if stmt.strip():
session.execute(text(stmt), {"lo": lo, "hi": hi})
rows = session.execute(text(_SELECT_SQL)).all()
n = len(rows)
if apply and rows:
session.execute(
text(
"""
DELETE FROM scene_performers sp
USING (SELECT * FROM ev e
WHERE NOT e.ma_dowod
AND EXISTS (SELECT 1 FROM ev e2
WHERE e2.scene_id = e.scene_id
AND e2.performer_id <> e.performer_id
AND e2.ma_dowod
AND e2.tokeny && e.tokeny)) d
WHERE sp.scene_id = d.scene_id AND sp.performer_id = d.performer_id
"""
)
)
total += n
log.info("zakres %s: %d przypisan%s", prefix, n, " (skasowane)" if apply else " (dry-run)")
print(f" zakres {prefix}: {n}{' skasowanych' if apply else ' do skasowania'}", flush=True)
return {"matched": total}
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(message)s")
ap = argparse.ArgumentParser()
ap.add_argument("--yes", action="store_true", help="wykonaj DELETE (bez tego dry-run)")
args = ap.parse_args()
out = run(apply=args.yes)
print(f"\nRAZEM: {out['matched']} {'skasowanych' if args.yes else 'do skasowania (dry-run)'}")
if __name__ == "__main__":
main()