fix(anime): 파이프라인 5건 수정 — 에피소드 정규식(v2/S01E), 릴리스 그룹 필터, 자막 보호, 배치 다운로드, 타임아웃
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user