fix(anime): 파이프라인 5건 수정 — 에피소드 정규식(v2/S01E), 릴리스 그룹 필터, 자막 보호, 배치 다운로드, 타임아웃

This commit is contained in:
2026-03-15 08:27:08 +09:00
parent 63818999d9
commit 9f74812710
40 changed files with 2759 additions and 815 deletions

View File

@@ -10,9 +10,70 @@ import unicodedata
from difflib import SequenceMatcher
from typing import Optional
import httpx
logger = logging.getLogger("variet.tools.matcher")
# ──────────────────────────────────────────────
# 영어 제목 조회 (Jikan API / MyAnimeList)
# ──────────────────────────────────────────────
async def fetch_english_title(japanese_title: str) -> dict[str, str]:
"""Jikan API로 일본어 원제의 영어/로마자 제목 조회.
Returns:
{"default": "Sousou no Frieren 2nd Season",
"english": "Frieren: Beyond Journey's End Season 2",
"synonyms": ["Frieren at the Funeral Season 2"]}
실패 시 빈 dict.
"""
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
"https://api.jikan.moe/v4/anime",
params={"q": japanese_title, "limit": 5},
)
resp.raise_for_status()
data = resp.json()
items = data.get("data", [])
if not items:
return {}
# 원제와 가장 잘 매칭되는 항목 선택
best = None
best_score = 0.0
for item in items:
jp = item.get("title_japanese", "")
score = SequenceMatcher(None, japanese_title, jp).ratio()
if score > best_score:
best_score = score
best = item
if not best or best_score < 0.5:
return {}
result = {
"default": best.get("title", ""),
"english": best.get("title_english") or "",
"synonyms": [],
}
for t in best.get("titles", []):
if t["type"] == "Synonym":
result["synonyms"].append(t["title"])
logger.info(
f"Jikan 영어 제목 조회: {japanese_title}"
f"default={result['default']}, english={result['english']}"
)
return result
except Exception as e:
logger.warning(f"Jikan API 조회 실패: {e}")
return {}
# ──────────────────────────────────────────────
# 일어 → 로마자 변환 테이블 (히라가나/카타카나)
# ──────────────────────────────────────────────
@@ -66,24 +127,35 @@ def _kata_to_hira(text: str) -> str:
def japanese_to_romaji(text: str) -> str:
"""일본어 텍스트를 로마자로 근사 변환."""
"""일본어 텍스트를 로마자로 변환 (pykakasi 기반, fallback: 카나 테이블)."""
try:
import pykakasi
kks = pykakasi.kakasi()
result_items = kks.convert(text)
romaji = " ".join(item["hepburn"] for item in result_items)
# 연속 공백 정리
romaji = re.sub(r'\s+', ' ', romaji).strip()
return romaji
except ImportError:
logger.warning("pykakasi 미설치 — 카나 테이블 fallback 사용")
return _japanese_to_romaji_fallback(text)
def _japanese_to_romaji_fallback(text: str) -> str:
"""일본어→로마자 fallback (카나만 변환, 한자는 그대로)."""
text = _kata_to_hira(text)
result = []
i = 0
while i < len(text):
# 장음 기호 (ー U+30FC, ー가 히라가나로 안 변환되므로 여기서 처리)
if text[i] == '\u30FC': # ー
# 장음: 이전 모음 반복 (간략화: 스킵)
i += 1
continue
# 2글자 매칭 우선 (きゃ 등)
if i + 1 < len(text) and text[i:i+2] in _KANA_ROMAJI:
result.append(_KANA_ROMAJI[text[i:i+2]])
i += 2
elif text[i] in _KANA_ROMAJI:
romaji = _KANA_ROMAJI[text[i]]
# 촉음(っ) 처리: 다음 자음 반복
if text[i] == '' and i + 1 < len(text):
next_romaji = _KANA_ROMAJI.get(text[i+1], "")
if next_romaji:
@@ -92,13 +164,13 @@ def japanese_to_romaji(text: str) -> str:
result.append(romaji)
i += 1
else:
# 한자, 영어, 숫자 등 → 그대로
result.append(text[i])
i += 1
return "".join(result)
def normalize_title(title: str) -> str:
"""제목 비교용 정규화: 소문자 + 특수문자 제거 + 공백 정리."""
title = title.lower().strip()
@@ -155,8 +227,14 @@ def match_titles(
best_sim = max(sim_romaji, sim_korean, sim_original)
if best_sim >= threshold:
scored.append((best_sim, result))
# ASW HEVC 릴리스는 threshold 면제 (약칭으로 올라와도 포함)
title_upper = result.title.upper()
is_preferred = "[ASW]" in result.title and ("HEVC" in title_upper or "X265" in title_upper)
if best_sim >= threshold or is_preferred:
# ASW HEVC 릴리스는 유사도 보너스 (+0.5) → 정렬 시 상위 배치
effective_sim = best_sim + 0.5 if is_preferred else best_sim
scored.append((effective_sim, result))
# 유사도 내림차순 정렬
scored.sort(key=lambda x: x[0], reverse=True)