Доразметка студий: nnm «Перевод: … (Студия)», kinozal из заголовка раздачи
* harvest_core.nnm_part_studio: студия из части строки «Перевод:» nnm («Дублированный (Невафильм)», «… - Jaskier»); раньше такие строки шли с «(не указана)», и модель училась не извлекать студию даже из «Дублированный (Пифагор)». tech-строки со студиями больше не выбрасываются, если на странице нет строк «Аудио N» с именами. * harvest_core.kz_title_rows: kinozal, студии из заголовка «ПМ (LostFilm, HDrezka Studio)», которых нет на странице; вход — сегмент как у сервиса, строка «Аудио Rus: … | Студия» — в audio_raw. * tools/relabel_studios.py — то же по готовому labels.csv без пересборки страниц; применено к data/labels.csv: со студией 22 061 -> 29 331 строк (nnm +3 051, kinozal +4 219 строк в 3 007 релизах); prepare_data: 39 418 строк, спан студии 24 004 -> 31 214. * e2e: эталон как aggregate — «тип без студии» убирается, если у типа есть студия. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f91f6cb48e
commit
83bab13893
6 files changed
+7488
-3056
No files matched your search
+7270
-3051
File diff suppressed because it is too large.
Load diff
@@ -142,6 +142,23 @@ id разных трекеров не пересекались. Коллизий
|
||||
повторяют логику правил. Для нарезки внутри строки («Дублированный,
|
||||
многоголосый») нужна ручная проверка — режим ревью ещё не сделан.
|
||||
|
||||
Студии, которые парсеры раньше теряли (доразметка от 02.11.2026):
|
||||
|
||||
* **nnm, «Перевод: Дублированный (Невафильм)»** — строки «Перевод:» (tech) шли с
|
||||
«(не указана)», хотя студия названа в той же части. Модель училась её не извлекать.
|
||||
Теперь — `nnm_part_studio` (скобки или «… - Студия»; тип, «x2», «Лицензия», источник
|
||||
вроде iTunes/Blu-ray — не студия). +3 051 студия.
|
||||
* **kinozal, заголовок «… / ПМ (LostFilm, HDrezka Studio), СТ / …»** — во «Техданных»
|
||||
только «Перевод: Профессиональный многоголосый», студии названы в заголовке и в
|
||||
строках «Аудио Rus: … | LostFilm» без типа. Теперь — `kz_title_rows`: строка на каждую
|
||||
студию из заголовка, которой нет на странице (`studio_source=title`, вход — тот же
|
||||
сегмент, что строит сервис из заголовка; строка «Аудио» с этой студией — в
|
||||
`audio_raw`). +4 219 строк в 3 007 релизах.
|
||||
|
||||
Сборщик делает это при переиндексации. Для уже собранного `labels.csv` без пересборки
|
||||
страниц: `.venv\Scripts\python tools\relabel_studios.py` (копия — `data/labels.bak_relabel_*.csv`;
|
||||
`--dry-run` — только статистика; повторный запуск ничего не добавляет).
|
||||
|
||||
## Разнообразная выборка (режим balanced)
|
||||
|
||||
Списки разделов идут от новых к старым, поэтому чтение подряд даёт только свежие
|
||||
|
||||
@@ -41,6 +41,10 @@ def run(predictor, rows, mode: str, test_only: bool) -> dict:
|
||||
if len(per_src[src]) >= MAX_PER_SOURCE:
|
||||
continue
|
||||
gold = {(r["translation_type"], r["studio"] or None) for r in by_rel[rid]}
|
||||
# как aggregate: «тип без студии» не отдельная дорожка, если у типа есть студия
|
||||
# (kinozal: «Перевод: многоголосый» + студии из заголовка)
|
||||
named = {t for t, s in gold if s}
|
||||
gold = {(t, s) for t, s in gold if s or t not in named}
|
||||
res = predict_release(predictor, text=page_text(page), segmenter=mode)
|
||||
pred = {(t["translation_type"], t["studio"]) for t in res["tracks"]}
|
||||
tp = len(gold & pred)
|
||||
|
||||
@@ -926,6 +926,24 @@ check("prepare_data: у редкого языка метка пустая", [r["
|
||||
|
||||
print("== метки: тип, раскладка, строки из «Аудио», веса классов ==")
|
||||
import harvest_core as _hc2 # noqa: E402
|
||||
_ps = _hc2.nnm_part_studio
|
||||
check("nnm «Перевод:»: студия из скобок и после тире",
|
||||
_ps("Дублированный (Невафильм)") == "Невафильм" and _ps("Многоголосый профессиональный - Jaskier") == "Jaskier"
|
||||
and _ps("Авторский (Живов") == "Юрий Живов")
|
||||
check("nnm «Перевод:»: тип, кратность, лицензия, соседнее поле — не студия",
|
||||
_ps("Двуголосый профессиональный") == "" and _ps("Одноголосый x2") == ""
|
||||
and _ps("Дублированный (Лицензия BD CEE)") == ""
|
||||
and _ps("одноголосый закадровый Дата мировой премьеры: 4 февраля 1983 (USA)") == "")
|
||||
_kzr = _hc2.kz_title_rows(
|
||||
{"id": "kz-1", "h1": "Что, если...? / What If...? / 2023 / ПМ (LostFilm, HDrezka Studio), СТ / WEB-DL"},
|
||||
"Аудио Rus: AC3, 48 kHz, 2 ch, 384 Кбит/сек | LostFilm",
|
||||
[{"studio": "(не указана)", "raw": "Перевод: Профессиональный многоголосый", "translation_type": "проф. многоголосый"}])
|
||||
check("kinozal: студии из заголовка, строка «Аудио» — в audio_raw",
|
||||
[(r["translation_type"], r["studio"], r["studio_source"], bool(r["audio_raw"])) for r in _kzr]
|
||||
== [("проф. многоголосый", "LostFilm", "title", True), ("проф. многоголосый", "HDrezka", "title", False)])
|
||||
check("kinozal: студия, уже названная на странице, из заголовка не дублируется",
|
||||
_hc2.kz_title_rows({"id": "kz-1", "h1": "X / Y / 2023 / ПМ (LostFilm)"}, "",
|
||||
[{"studio": "LostFilm", "raw": "Перевод: … LostFilm"}]) == [])
|
||||
_tt = _hc2.translation_type
|
||||
check("тип: любительский дубляж — отдельный класс", _tt("Любительский (дублированный) Red Head Sound")[0] == "люб. дубляж")
|
||||
check("тип: псевдодубляж -> люб. дубляж", _tt("Любительский (псевдодублированный)")[0] == "люб. дубляж")
|
||||
|
||||
@@ -1460,6 +1460,29 @@ def nnm_studio(name: str) -> tuple[str, str]:
|
||||
return cleaned, " ".join(x for x in (note, extra) if x).strip()
|
||||
|
||||
|
||||
# слова, которые в «Перевод: <тип> - <студия>» не студия: тип, кратность, язык
|
||||
NNM_PART_TYPEISH = re.compile(
|
||||
r"(?i)голос|дубл|закадр|авторск|любител|профессион|русск|оригинал|субтитр|лицензи"
|
||||
r"|^\W*[xх]?\d+[xх]?\W*$")
|
||||
|
||||
|
||||
def nnm_part_studio(part: str) -> str:
|
||||
"""Студия из части строки «Перевод:» nnm: «Многоголосый профессиональный - Jaskier»,
|
||||
«Дублированный (Невафильм)», «Авторский (Живов» -> имя; «Дублированный (Лицензия)»,
|
||||
«Одноголосый x2», «Двуголосый профессиональный» -> ""."""
|
||||
if len(part) > 90 or re.search(r"(?i)премьер|\bдата\b", part):
|
||||
return "" # склеилось соседнее поле описания
|
||||
m = re.search(r"\s[-–—:]\s*(.+)$", part)
|
||||
cands = ([m.group(1)] if m else []) + [c for c in re.findall(r"\(([^()]*)\)?", part) if c]
|
||||
for cand in cands:
|
||||
studio, _ = clean_studio(cand)
|
||||
studio, _ = canonical_studio(studio)
|
||||
if len(studio) >= 2 and not NNM_PART_TYPEISH.search(studio) \
|
||||
and studio.lower() not in SOURCE_LIKE:
|
||||
return studio
|
||||
return ""
|
||||
|
||||
|
||||
def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
post = rec.get("post") or ""
|
||||
title = rec.get("page_title") or rec.get("title") or ""
|
||||
@@ -1539,20 +1562,21 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
if ttype in ("неизвестно", "оригинал", "субтитры"):
|
||||
continue
|
||||
perevod_types.append(part)
|
||||
# без имён в скобках студия неизвестна, но тип перевода ценен
|
||||
# студия — если названа в части («Дублированный (Невафильм)»); без неё тип всё равно ценен
|
||||
rows.append({
|
||||
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
|
||||
"year": year, "quality": quality, "is_series": is_series,
|
||||
"studio": "(не указана)", "translation_type": ttype,
|
||||
"studio": nnm_part_studio(part) or "(не указана)", "translation_type": ttype,
|
||||
"professional": pro, "role_raw": part, "actors": "",
|
||||
"note": mark_18("", has_18(line)), "age_18": int(has_18(line)),
|
||||
"codec": "", "channels": "", "khz": "", "kbps": "",
|
||||
"subtitles": "", "studio_source": "tech", "raw": line,
|
||||
})
|
||||
|
||||
# если построчных «Аудио N» с именами не было — оставляем только типы
|
||||
if any(r["studio"] != "(не указана)" for r in rows):
|
||||
rows = [r for r in rows if r["studio"] != "(не указана)"]
|
||||
# есть построчные «Аудио N» с именами — строки «Перевод:» (tech) не нужны; иначе
|
||||
# оставляем tech-строки: типы и студии, названные в «Перевод:»
|
||||
if any(r["studio"] != "(не указана)" and r["studio_source"] == "release" for r in rows):
|
||||
rows = [r for r in rows if r["studio_source"] == "release" and r["studio"] != "(не указана)"]
|
||||
|
||||
# дорожка без имени и без типа («- Русский») ничего не даёт датасету
|
||||
rows = [r for r in rows
|
||||
@@ -1715,6 +1739,51 @@ def audio_only_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def kz_title_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
|
||||
"""kinozal: студии из заголовка раздачи «… / ПМ (LostFilm, HDrezka Studio), ДБ (X) / …».
|
||||
|
||||
На kinozal студии часто названы только в заголовке и в строках «Аудио Rus: … | LostFilm»
|
||||
(без типа), а «Перевод:» во «Техданных» — без студий. Строка-вход — тот же синтетический
|
||||
сегмент, что строит сервис из заголовка (src.release.title_segments); строка «Аудио» с этой
|
||||
студией (если есть) — в audio_raw (позитив для сегментатора).
|
||||
"""
|
||||
from src.release import RE_TITLE_TRACK, TITLE_TYPES
|
||||
|
||||
def key(x: str) -> str:
|
||||
return re.sub(r"[\W_]+", "", (x or "").casefold())
|
||||
|
||||
title = (rec.get("h1") or rec.get("page_title") or "").split(" :: ")[0]
|
||||
have = key(" ".join(((r.get("studio") or "") + " " + (r.get("raw") or "")) for r in rows))
|
||||
lines = [ln for ln in audio_lines_all(text) if not ln["original"] and not ln["commentary"]]
|
||||
base = rows[0] if rows else {}
|
||||
info = kz_split_title(title)
|
||||
out = []
|
||||
for abbr, names in RE_TITLE_TRACK.findall(title):
|
||||
for name in re.split(r"\s*[,;]\s*", names.strip()):
|
||||
studio, note = clean_studio(name)
|
||||
studio, extra = canonical_studio(studio)
|
||||
k = key(studio)
|
||||
if len(k) < 2 or k in have or studio.lower() in SOURCE_LIKE:
|
||||
continue
|
||||
kind, pro = translation_type(TITLE_TYPES[abbr])
|
||||
a = next((ln for ln in lines if k in key(ln["raw"])), None)
|
||||
out.append({
|
||||
"source": "kinozal", "release_id": rec.get("id", ""),
|
||||
"ru_title": base.get("ru_title") or info["ru_title"],
|
||||
"year": base.get("year") or info["year"], "quality": base.get("quality", ""),
|
||||
"is_series": base.get("is_series", bool(re.search(r"сезон|серии", title, re.I))),
|
||||
"studio": studio, "translation_type": kind, "professional": pro,
|
||||
"role_raw": TITLE_TYPES[abbr], "actors": "",
|
||||
"note": mark_18(" ".join(x for x in (note, extra) if x).strip(), bool(a and a["adult"])),
|
||||
"age_18": int(bool(a and a["adult"])),
|
||||
"codec": a["codec"] if a else "", "channels": a["channels"] if a else "", "khz": "",
|
||||
"kbps": a["kbps"] if a else "", "subtitles": "", "studio_source": "title",
|
||||
"raw": f"{TITLE_TYPES[abbr]} | {name.strip()}",
|
||||
"lang": (a["lang"] or "RUS") if a else "RUS", "audio_raw": a["raw"] if a else ""})
|
||||
have += k
|
||||
return out
|
||||
|
||||
|
||||
def _with_audio(core, texts):
|
||||
"""Обёртка парсера: lang/audio_raw у строк; число переводов в rel — по итоговым строкам."""
|
||||
def parse(rec: dict) -> tuple[list[dict], dict]:
|
||||
@@ -1725,6 +1794,8 @@ def _with_audio(core, texts):
|
||||
rows += audio_only_rows(rec, text, rows)
|
||||
elif core is _kz_parse_core:
|
||||
rows = kz_audio_only_rows(rec, text)
|
||||
if core is _kz_parse_core:
|
||||
rows += kz_title_rows(rec, text, rows)
|
||||
if rows:
|
||||
rel["n_translations"], rel["ok"] = len(rows), True
|
||||
rel["studios"] = " | ".join(sorted({r["studio"] for r in rows}))
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Доразметка студий в data/labels.csv без пересборки страниц.
|
||||
|
||||
То же, что теперь делает сборщик (tools/harvest/harvest_core.py) при переиндексации,
|
||||
но по готовому labels.csv и data/raw — минуты вместо полного разбора HTML:
|
||||
|
||||
* nnm, строки «Перевод:» (studio_source=tech) с «(не указана)»: студия, названная
|
||||
в самой части строки («Дублированный (Невафильм)», «… - Jaskier») — nnm_part_studio;
|
||||
* kinozal: студии из заголовка раздачи «ПМ (LostFilm, HDrezka Studio)», которых нет
|
||||
среди строк релиза (studio_source=title) — kz_title_rows по data/raw/kinozal/<id>.json.
|
||||
|
||||
Ручные релизы (data/manual_ids.txt) не трогаются. Перед записью — копия
|
||||
data/labels.bak_relabel_<время>.csv. Повторный запуск ничего не добавляет.
|
||||
|
||||
.venv\\Scripts\\python tools\\relabel_studios.py # применить
|
||||
.venv\\Scripts\\python tools\\relabel_studios.py --dry-run # только статистика
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "tools" / "harvest"))
|
||||
|
||||
import harvest_core as H # noqa: E402
|
||||
|
||||
LABELS = ROOT / "data" / "labels.csv"
|
||||
RAW_KZ = ROOT / "data" / "raw" / "kinozal"
|
||||
MANUAL = ROOT / "data" / "manual_ids.txt"
|
||||
NO_STUDIO = "(не указана)"
|
||||
|
||||
|
||||
def relabel(rows: list[dict], raw_kz: Path = RAW_KZ, manual: set[str] | None = None) -> tuple[list[dict], Counter]:
|
||||
"""rows -> (новые rows, статистика). Исходные dict меняются на месте."""
|
||||
manual = manual or set()
|
||||
stats: Counter = Counter()
|
||||
for r in rows:
|
||||
if r["source"] == "nnm" and r.get("studio_source") == "tech" and r["studio"] == NO_STUDIO \
|
||||
and r["release_id"] not in manual:
|
||||
studio = H.nnm_part_studio(r.get("role_raw") or "")
|
||||
if studio:
|
||||
r["studio"] = studio
|
||||
stats["nnm: студия из «Перевод:»"] += 1
|
||||
by_rel: dict[str, list[dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
if r["source"] == "kinozal":
|
||||
by_rel[r["release_id"]].append(r)
|
||||
added: list[dict] = []
|
||||
for rid, rel_rows in by_rel.items():
|
||||
if rid in manual:
|
||||
continue
|
||||
path = raw_kz / f"{rid.removeprefix('kz-')}.json"
|
||||
if not path.exists():
|
||||
stats["kinozal: нет raw JSON"] += 1
|
||||
continue
|
||||
rec = json.loads(path.read_text(encoding="utf-8"))
|
||||
rec["id"] = rid
|
||||
text = "\n".join(x for x in (rec.get("release_tab") or "", rec.get("tech_tab") or "") if x)
|
||||
new = H.kz_title_rows(rec, text, rel_rows)
|
||||
if new:
|
||||
stats["kinozal: релизов со студиями из заголовка"] += 1
|
||||
stats["kinozal: студий из заголовка"] += len(new)
|
||||
stats["kinozal: из них со строкой «Аудио»"] += sum(bool(x["audio_raw"]) for x in new)
|
||||
added += new
|
||||
return rows + added, stats
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--dry-run", action="store_true", help="только посчитать, файл не менять")
|
||||
args = ap.parse_args()
|
||||
with LABELS.open(encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
fields = reader.fieldnames
|
||||
rows = list(reader)
|
||||
manual = set(MANUAL.read_text(encoding="utf-8").split()) if MANUAL.exists() else set()
|
||||
before = sum(r["studio"] != NO_STUDIO for r in rows)
|
||||
rows, stats = relabel(rows, manual=manual)
|
||||
after = sum(r["studio"] != NO_STUDIO for r in rows)
|
||||
for k, v in stats.items():
|
||||
print(f"{k}: {v}")
|
||||
print(f"строк: {len(rows)}; со студией: {before} -> {after}")
|
||||
if args.dry_run:
|
||||
return
|
||||
backup = LABELS.with_name(f"labels.bak_relabel_{time.strftime('%Y%m%d_%H%M%S')}.csv")
|
||||
shutil.copy(LABELS, backup)
|
||||
with LABELS.open("w", encoding="utf-8-sig", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
print(f"записано: {LABELS.relative_to(ROOT)}; копия: {backup.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user