Files
grabber-ai/src/release.py
T

149 lines
7.1 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
# аббревиатуры типа озвучки в заголовках раздач -> фраза, понятная модели
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 predict_release(predictor, *, html: str | None = None,
text: str | None = None, title: str | None = None) -> dict:
"""Предсказание для релиза целиком (html или текст страницы).
Заголовок раздачи используется как дополнительный источник: студии,
названные только там («ПМ (LostFilm)»), добавляются как дорожки с
пометкой source=title.
"""
if (html is None) == (text is None):
raise ValueError("нужно ровно одно из: html, text")
tracks = predictor.predict_html(html) if html is not None else predictor.predict_page(text)
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
return result