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:
parent
a16883d2f3
commit
5217d07222
3 changed files with 176 additions and 10 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
130
scripts/prune_token_overlap_attributions.py
Normal file
130
scripts/prune_token_overlap_attributions.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue