"""Результат на уровне релиза: дорожки, студии, 18+. Публичный API: title_from_html(html) — заголовок раздачи (/<h1>) title_segments(title) — синтетические сегменты из заголовка («ПМ (LostFilm)» -> «Профессиональный (многоголосый закадровый) | LostFilm») aggregate(tracks) — дедуп дорожек + список студий + is_18plus predict_release(predictor, html=..., text=..., url=..., title=...) """ from __future__ import annotations import re from bs4 import BeautifulSoup from .html_extract import (RE_LINE_PREFIX, _split_entries, extract_segments, html_to_text) # аббревиатуры типа озвучки в заголовках раздач -> фраза, понятная модели TITLE_TYPES = { "ПМ": "Профессиональный (многоголосый закадровый)", "ПД": "Профессиональный (двухголосый закадровый)", "ПО": "Профессиональный (одноголосый закадровый)", "ЛМ": "Любительский (многоголосый закадровый)", "ЛД": "Любительский (двухголосый закадровый)", "ЛО": "Любительский (одноголосый закадровый)", "АП": "Авторский (одноголосый закадровый)", "ДБ": "Профессиональный (дублированный)", "Дубляж": "Профессиональный (дублированный)", } _ABBR = "|".join(sorted(map(re.escape, TITLE_TYPES), key=len, reverse=True)) # «ПМ (LostFilm)», «ДБ (Red Head Sound)»; студия обязательна — без неё нечего добавлять RE_TITLE_TRACK = re.compile(rf"(?<![\w])({_ABBR})\s*\(([^()]{{2,60}})\)") def title_from_html(html: str) -> str: soup = BeautifulSoup(html, "lxml") for sel in ("h1", "title"): node = soup.select_one(sel) if node and node.get_text(strip=True): return re.sub(r"\s+", " ", node.get_text(" ", strip=True)) return "" # заголовок раздачи в текстовом дампе: «Название / ... / 2025 / ... » RE_TITLE_LINE = re.compile(r"^.{3,300}?\s/\s.*\b(?:19|20)\d\d\b.*\s/\s.*$") def title_from_text(text: str) -> str: """Первая строка текстового дампа, похожая на заголовок раздачи. Дальше по странице идут списки других раздач с такими же строками, поэтому берётся только первое совпадение. """ for line in (text or "").splitlines(): line = line.strip() if RE_TITLE_LINE.match(line): return line return "" def title_segments(title: str) -> list[str]: """Дорожки, перечисленные в заголовке раздачи в формате «ТИП (студия)».""" out = [] for abbr, studio in RE_TITLE_TRACK.findall(title or ""): for name in re.split(r"\s*[,;]\s*", studio.strip()): if name: out.append(f"{TITLE_TYPES[abbr]} | {name}") return out def _key(t: dict) -> tuple: return (t["translation_type"], (t.get("studio") or t["segment"]).casefold()) def aggregate(tracks: list[dict]) -> dict: """Дорожки -> агрегат релиза. Дубли (тип + студия) схлопываются. Если одна и та же дорожка есть и в описании, и в заголовке, остаётся версия из описания (она идёт первой), 18+ объединяется по OR. """ merged: dict[tuple, dict] = {} for t in tracks: k = _key(t) if k in merged: merged[k]["is_18plus"] = merged[k]["is_18plus"] or t["is_18plus"] else: merged[k] = dict(t) uniq = list(merged.values()) # дорожка без студии, при наличии дорожки того же типа со студией, — как # правило, та же дорожка в виде кодек-строки («AC3 … | MVO, AlexFilm») typed = {t["translation_type"] for t in uniq if t.get("studio")} uniq = [t for t in uniq if t.get("studio") or t["translation_type"] not in typed] studios = [{"studio": t["studio"], "translation_type": t["translation_type"], "is_18plus": t["is_18plus"]} for t in uniq if t.get("studio")] return { "tracks": uniq, "studios": studios, "translation_types": sorted({t["translation_type"] for t in uniq}), "is_18plus": any(t["is_18plus"] for t in uniq), } def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None: """Добавляет дорожки из заголовка к дорожкам страницы (на месте). * студия уже есть на странице -> пропуск; * на странице есть дорожка того же типа без студии -> студия берётся из заголовка (source=page+title): это та же дорожка; * иначе -> новая дорожка (source=title). """ for t in title_tracks: name = (t.get("studio") or "").casefold() if any((x.get("studio") or "").casefold() == name for x in tracks): continue same = next((x for x in tracks if not x.get("studio") and x["translation_type"] == t["translation_type"]), None) if same is not None: same.update(studio=t["studio"], studio_span=t["studio_span"], studio_confidence=t["studio_confidence"], is_18plus=same["is_18plus"] or t["is_18plus"], source="page+title") else: t["source"] = "title" tracks.append(t) def model_segments(segmenter, page_text: str) -> list[str]: """Дорожки страницы: строки выбирает обученный сегментатор. Нарезка внутри строки («Дублированный, многоголосый») в данных не размечена и пока делается правилами `_split_entries`. """ out = [] for line in segmenter.select_lines(page_text): body = RE_LINE_PREFIX.sub("", line, count=1) for entry in _split_entries(body): entry = re.sub(r"\s+", " ", entry).strip() if entry: out.append(entry) return out def predict_release(predictor, *, html: str | None = None, text: str | None = None, title: str | None = None, segmenter: str = "model") -> dict: """Предсказание для релиза целиком (html или текст страницы). segmenter: "model" — строки-дорожки выбирает обученный сегментатор (если он загружен и нашёл хотя бы одну строку), иначе правила; "rules" — только правила. Заголовок раздачи используется как дополнительный источник: студии, названные только там («ПМ (LostFilm)»), добавляются как дорожки с пометкой source=title. """ if (html is None) == (text is None): raise ValueError("нужно ровно одно из: html, text") page_text = html_to_text(html) if html is not None else text segs_page, used = [], "rules" seg_model = getattr(predictor, "segmenter", None) if segmenter == "model" and seg_model is not None: segs_page = model_segments(seg_model, page_text) used = "model" if segs_page else "rules" if not segs_page: segs_page = extract_segments(page_text) tracks = predictor._predict_batch(segs_page) if segs_page else [] for t in tracks: t["source"] = "page" if title is None: title = title_from_html(html) if html is not None else title_from_text(text) segs = title_segments(title or "") if segs: merge_title_tracks(tracks, predictor._predict_batch(segs)) result = aggregate(tracks) result["title"] = title or None result["segmenter"] = used return result