181 lines
8.6 KiB
Python
181 lines
8.6 KiB
Python
"""Результат на уровне релиза: дорожки, студии, 18+.
|
|
|
|
Публичный API:
|
|
title_from_html(html) — заголовок раздачи (<title>/<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
|