"""Studio extraction z tube tytułów typu aggregator-WordPress. Format obserwowany w istniejących scenach (zaingestowanych przez porn-app legacy): - `[StudioCamelCase] Performer1, Performer2 (Title)` — porndish, xmoviesforyou - `[StudioCamelCase] Performer (Title / date)` — porndish, xmoviesforyou z datą - `Studio – Performer1 – Title` — watchporn, hdporn92 (en/em-dash separator) - `Studio – Performer1 & Performer2 – Title – S24:E3` — watchporn series Powód: te tube'y aggregator-WordPress mają `studio_name` ustawione na nazwę source'a (`PornDish`, `Watch.Porn`) zamiast prawdziwego studio (`OpenFamily`, `TouchMyWife`). Resolver path 4 composite scoring blokuje po `studio_id + release_date` — z błędnym studio_id znajduje 0 kandydatów → fallback na performer-only blocking → bez strong signal (duration/fp/date) score capuje 0.85 < 0.92 threshold → orphan. Po retro-fix studio_id na prawdziwe canonical studio, path 4 znajduje kandydatów i performer + duration + title fuzzy może auto-merge. """ from __future__ import annotations import re from dataclasses import dataclass from datetime import date # Bracket format: `[Studio] Performers (Title)` lub `[Studio] Performers (Title / Date)`. # Studio musi być przynajmniej 2 chars, no whitespace. Tolerujemy hyphen w studio # (np. `[Passion-HD]`, `[PervMom-Squirts]`). _BRACKET_RE = re.compile( r'^\[(?P[A-Za-z0-9][A-Za-z0-9 \-\.]{1,40})\]\s+(?P.+?)$' ) # Em/en/regular dash format: `Studio – Performers – Title [– Episode]`. Studio # musi być max 35 chars + zaczyna alfanumerycznie. Pierwszy segment przed pierwszym # dashem to studio. Dash separator może być różny: en-dash (–), em-dash (—), # regular hyphen z spacjami `– ` / `- `. Wymagamy że studio jest co najmniej 3 znaki # żeby uniknąć `S24:E3` style false-positive. _DASH_RE = re.compile( r'^(?P[A-Za-z][A-Za-z0-9 \-\.]{2,35}?)\s+[–—\-]+\s+(?P.+?)$' ) # Tytuły które są TYLKO slug-concat (lowercase, brak struktury) — nie parsujemy. _LOWERCASE_RE = re.compile(r'^[a-z0-9 ]+$') @dataclass class ParsedTitle: studio: str | None # raw studio name as extracted (e.g. "OpenFamily") title_remainder: str # rest of title after studio extracted format: str # 'bracket' | 'dash' | 'none' release_date: date | None = None # parsed z `(... / MM.DD.YYYY)` lub `MM/DD/YYYY` clean_title: str | None = None # tytuł sceny bez studio + bez date suffix # Date patterns w `[Studio] X (Title / MM.DD.YYYY)` lub similar. # Porndish & xmoviesforyou używają `MM.DD.YYYY` (american). Czasem widać też # `MM/DD/YYYY`. Hyphen w roku nigdy. _DATE_RE = re.compile( r'(?P0[1-9]|1[0-2])[\./](?P0[1-9]|[12][0-9]|3[01])[\./](?P20\d{2})' ) def _parse_date_from_tail(remainder: str) -> tuple[date | None, str]: """Wyciągnij datę z `(Title / MM.DD.YYYY)` lub `(Title / MM/DD/YYYY)`. Returns (parsed_date | None, remainder_without_date_suffix). """ m = _DATE_RE.search(remainder) if not m: return None, remainder try: d = date(int(m.group("y")), int(m.group("m")), int(m.group("d"))) except (ValueError, TypeError): return None, remainder # Drop everything from `/` lub `(` lub ` MM.DD` boundary up to date cut_pos = m.start() # Walk back through separators ` / ` lub `(` before date while cut_pos > 0 and remainder[cut_pos - 1] in " /([": cut_pos -= 1 cleaned = (remainder[:cut_pos] + remainder[m.end():]).strip(" ()/-,") return d, cleaned def parse_title(title: str) -> ParsedTitle: """Wyparsuj studio z tytułu sceny. Returns ParsedTitle(studio=None) gdy nie wykryto formatu — wtedy caller powinien pominąć fix dla tej sceny. """ if not title: return ParsedTitle(studio=None, title_remainder=title or "", format="none") title = title.strip() # Lower-only slug-concat — bez sensu próbować if _LOWERCASE_RE.match(title): return ParsedTitle(studio=None, title_remainder=title, format="none") m = _BRACKET_RE.match(title) if m: studio = m.group("studio").strip() rest = m.group("rest").strip() rel_date, cleaned_rest = _parse_date_from_tail(rest) # Extract inner title z `Performers (Title)` — bierzemy tylko zawartość parens # jeśli istnieją; inaczej całą reszte inner = re.search(r'\(([^()]+)\)\s*$', cleaned_rest) clean_title = inner.group(1).strip() if inner else cleaned_rest return ParsedTitle( studio=studio, title_remainder=rest, format="bracket", release_date=rel_date, clean_title=clean_title or None, ) m = _DASH_RE.match(title) if m: studio = m.group("studio").strip() # Filter: studio nie może być znanym non-studio prefixem (e.g. "NEW", "HD", "VR"). # Te są częste w SEO tytułach i tworzyłyby false-positive studio_id. if studio.lower() in _NON_STUDIO_PREFIXES: return ParsedTitle(studio=None, title_remainder=title, format="none") rest = m.group("rest").strip() # Dash format: `Studio – Performers – Title – [Optional Episode]`. Tytuł sceny # to OSTATNI segment (po ostatnim dashu); performerzy to pre-last segments. parts = re.split(r'\s+[–—\-]+\s+', rest) clean_title = parts[-1] if parts else rest return ParsedTitle( studio=studio, title_remainder=rest, format="dash", clean_title=clean_title, ) return ParsedTitle(studio=None, title_remainder=title, format="none") # Słowa które pojawiają się jako pierwszy token tytułu ale NIE są studio names — # typowo SEO booster prefix lub jakość/kategoria/etykieta. _NON_STUDIO_PREFIXES = frozenset({ "new", "hd", "4k", "vr", "free", "watch", "porn", "video", "full", "anal", "best", "exclusive", "amateur", "homemade", "pov", "milf", "teen", "bbw", "bdsm", "interracial", "lesbian", "threesome", "gangbang", })