1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt, ' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety; zakresy 0–5 i A–Z na dywiz. Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach, czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu, tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana. Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie). Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika to zero zmiany. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
5.9 KiB
Python
141 lines
5.9 KiB
Python
"""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<studio>[A-Za-z0-9][A-Za-z0-9 \-\.]{1,40})\]\s+(?P<rest>.+?)$'
|
|
)
|
|
|
|
# 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<studio>[A-Za-z][A-Za-z0-9 \-\.]{2,35}?)\s+[–—\-]+\s+(?P<rest>.+?)$'
|
|
)
|
|
|
|
# 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'(?P<m>0[1-9]|1[0-2])[\./](?P<d>0[1-9]|[12][0-9]|3[01])[\./](?P<y>20\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",
|
|
})
|